diff --git a/cmd/kosli/attest.go b/cmd/kosli/attest.go new file mode 100644 index 000000000..3faf9c65b --- /dev/null +++ b/cmd/kosli/attest.go @@ -0,0 +1,29 @@ +package main + +import ( + "io" + + "github.com/spf13/cobra" +) + +const attestDesc = `All Kosli attest commands.` + +func newAttestCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "attest", + Short: attestDesc, + Long: attestDesc, + Hidden: true, + } + + // Add subcommands + cmd.AddCommand( + newAttestArtifactCmd(out), + newAttestGenericCmd(out), + newAttestSnykCmd(out), + newAttestJunitCmd(out), + newAttestJiraCmd(out), + newAttestPRCmd(out), + ) + return cmd +} diff --git a/cmd/kosli/attestArtifact.go b/cmd/kosli/attestArtifact.go new file mode 100644 index 000000000..9b319e5aa --- /dev/null +++ b/cmd/kosli/attestArtifact.go @@ -0,0 +1,208 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "path/filepath" + + "github.com/kosli-dev/cli/internal/gitview" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +type attestArtifactOptions struct { + fingerprintOptions *fingerprintOptions + flowName string + gitReference string + srcRepoRoot string + displayName string + payload AttestArtifactPayload +} + +type AttestArtifactPayload struct { + Fingerprint string `json:"fingerprint"` + Filename string `json:"filename"` + GitCommit string `json:"git_commit"` + BuildUrl string `json:"build_url"` + CommitUrl string `json:"commit_url"` + RepoUrl string `json:"repo_url"` + CommitsList []*gitview.CommitInfo `json:"commits_list"` + Name string `json:"step_name"` + TrailName string `json:"trail_name"` +} + +const attestArtifactShortDesc = `Attest an artifact creation to a Kosli flow. ` + +const attestArtifactLongDesc = attestArtifactShortDesc + ` +` + fingerprintDesc + +const attestArtifactExample = ` +# Attest to a Kosli flow that a file type artifact has been created +kosli attest artifact FILE.tgz \ + --artifact-type file \ + --build-url https://exampleci.com \ + --commit-url https://github.com/YourOrg/YourProject/commit/yourCommitShaThatThisArtifactWasBuiltFrom \ + --git-commit yourCommitShaThatThisArtifactWasBuiltFrom \ + --flow yourFlowName \ + --trail yourTrailName \ + --name yourTemplateArtifactName \ + --api-token yourApiToken \ + --org yourOrgName + + +# Attest to a Kosli flow that an artifact with a provided fingerprint (sha256) has been created +kosli attest artifact ANOTHER_FILE.txt \ + --build-url https://exampleci.com \ + --commit-url https://github.com/YourOrg/YourProject/commit/yourCommitShaThatThisArtifactWasBuiltFrom \ + --git-commit yourCommitShaThatThisArtifactWasBuiltFrom \ + --flow yourFlowName \ + --fingerprint yourArtifactFingerprint \ + --trail yourTrailName \ + --name yourTemplateArtifactName \ + --api-token yourApiToken \ + --org yourOrgName +` + +func newAttestArtifactCmd(out io.Writer) *cobra.Command { + o := new(attestArtifactOptions) + o.fingerprintOptions = new(fingerprintOptions) + cmd := &cobra.Command{ + Use: "artifact {IMAGE-NAME | FILE-PATH | DIR-PATH}", + Short: attestArtifactShortDesc, + Long: attestArtifactLongDesc, + Example: attestArtifactExample, + Args: cobra.MaximumNArgs(1), + Hidden: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + err = ValidateArtifactArg(args, o.fingerprintOptions.artifactType, o.payload.Fingerprint, true) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return ValidateRegistryFlags(cmd, o.fingerprintOptions) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + ci := WhichCI() + cmd.Flags().StringVarP(&o.payload.Fingerprint, "fingerprint", "F", "", fingerprintFlag) + cmd.Flags().StringVarP(&o.flowName, "flow", "f", "", flowNameFlag) + cmd.Flags().StringVarP(&o.gitReference, "git-commit", "g", DefaultValue(ci, "git-commit"), gitCommitFlag) + cmd.Flags().StringVarP(&o.payload.BuildUrl, "build-url", "b", DefaultValue(ci, "build-url"), buildUrlFlag) + cmd.Flags().StringVarP(&o.payload.CommitUrl, "commit-url", "u", DefaultValue(ci, "commit-url"), commitUrlFlag) + cmd.Flags().StringVar(&o.srcRepoRoot, "repo-root", ".", repoRootFlag) + cmd.Flags().StringVarP(&o.payload.Name, "name", "n", "", templateArtifactName) + cmd.Flags().StringVarP(&o.displayName, "display-name", "N", "", artifactDisplayName) + cmd.Flags().StringVarP(&o.payload.TrailName, "trail", "T", "", trailNameFlag) + addFingerprintFlags(cmd, o.fingerprintOptions) + + addDryRunFlag(cmd) + + err := RequireFlags(cmd, []string{"trail", "flow", "name", "git-commit", "build-url", "commit-url"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *attestArtifactOptions) run(args []string) error { + + if o.displayName != "" { + o.payload.Filename = o.displayName + } else { + if o.fingerprintOptions.artifactType == "dir" || o.fingerprintOptions.artifactType == "file" { + o.payload.Filename = filepath.Base(args[0]) + } else { + o.payload.Filename = args[0] + } + } + + if o.payload.Fingerprint == "" { + var err error + o.payload.Fingerprint, err = GetSha256Digest(args[0], o.fingerprintOptions, logger) + if err != nil { + return err + } + } + + gitView, err := gitview.New(o.srcRepoRoot) + if err != nil { + return err + } + + commitObject, err := gitView.GetCommitInfoFromCommitSHA(o.gitReference) + if err != nil { + return err + } + o.payload.GitCommit = commitObject.Sha1 + + previousCommit, err := o.latestCommit(currentBranch(gitView)) + if err == nil { + o.payload.CommitsList, err = gitView.ChangeLog(o.payload.GitCommit, previousCommit, logger) + if err != nil && !global.DryRun { + return err + } + } else if !global.DryRun { + return err + } + + o.payload.RepoUrl, err = gitView.RepoUrl() + if err != nil { + logger.Warning("Repo URL will not be reported, %s", err.Error()) + } + + url := fmt.Sprintf("%s/api/v2/artifacts/%s/%s", global.Host, global.Org, o.flowName) + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + Payload: o.payload, + DryRun: global.DryRun, + Password: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("artifact %s was attested with fingerprint: %s", o.payload.Filename, o.payload.Fingerprint) + } + return err +} + +// latestCommit retrieves the git commit of the latest artifact for a flow in Kosli +func (o *attestArtifactOptions) latestCommit(branchName string) (string, error) { + latestCommitUrl := fmt.Sprintf( + "%s/api/v2/artifacts/%s/%s/%s/latest_commit%s", + global.Host, global.Org, o.flowName, o.payload.Fingerprint, asBranchParameter(branchName)) + + reqParams := &requests.RequestParams{ + Method: http.MethodGet, + URL: latestCommitUrl, + Password: global.ApiToken, + } + response, err := kosliClient.Do(reqParams) + if err != nil { + return "", err + } + + var latestCommitResponse map[string]interface{} + err = json.Unmarshal([]byte(response.Body), &latestCommitResponse) + if err != nil { + return "", err + } + latestCommit := latestCommitResponse["latest_commit"] + if latestCommit == nil { + logger.Debug("no previous artifacts were found for flow: %s", o.flowName) + return "", nil + } else { + logger.Debug("latest artifact for flow: %s has the git commit: %s", o.flowName, latestCommit.(string)) + return latestCommit.(string), nil + } +} diff --git a/cmd/kosli/attestArtifact_test.go b/cmd/kosli/attestArtifact_test.go new file mode 100644 index 000000000..a2fcc5131 --- /dev/null +++ b/cmd/kosli/attestArtifact_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type AttestArtifactCommandTestSuite struct { + flowName string + trailName string + suite.Suite + defaultKosliArguments string +} + +func (suite *AttestArtifactCommandTestSuite) SetupTest() { + suite.flowName = "attest-artifact" + suite.trailName = "test-123" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --flow %s --trail %s --repo-root ../.. --host %s --org %s --api-token %s", suite.flowName, suite.trailName, global.Host, global.Org, global.ApiToken) + CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) + BeginTrail(suite.trailName, suite.flowName, "", suite.T()) +} + +func (suite *AttestArtifactCommandTestSuite) TestAttestArtifactCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when more arguments are provided", + cmd: fmt.Sprintf("attest artifact foo bar %s", suite.defaultKosliArguments), + golden: "Error: accepts at most 1 arg(s), received 2\n", + }, + { + wantError: true, + name: "fails when missing a required flag", + cmd: fmt.Sprintf("attest artifact foo --artifact-type file --name bar --git-commit HEAD --build-url example.com %s", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"commit-url\" not set\n", + }, + { + wantError: true, + name: "fails when --fingerprint is invalid sha256 digest", + cmd: fmt.Sprintf("attest artifact foo --fingerprint xxxx --name bar --git-commit HEAD --build-url example.com --commit-url example.com %s", suite.defaultKosliArguments), + golden: "Error: xxxx is not a valid SHA256 fingerprint. It should match the pattern ^([a-f0-9]{64})$\nUsage: kosli attest artifact {IMAGE-NAME | FILE-PATH | DIR-PATH} [flags]\n", + }, + { + wantError: true, + name: "fails when --name does not match artifact name in the template", + cmd: fmt.Sprintf("attest artifact testdata/file1 --artifact-type file --name bar --git-commit HEAD --build-url example.com --commit-url example.com %s", suite.defaultKosliArguments), + golden: "Error: Artifact 'bar' does not exist in trail template 'test-123'.\nAvailable artifacts: cli\n", + }, + { + name: "can attest a file artifact", + cmd: fmt.Sprintf("attest artifact testdata/file1 --artifact-type file --name cli --git-commit HEAD --build-url example.com --commit-url example.com %s", suite.defaultKosliArguments), + golden: "artifact file1 was attested with fingerprint: 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9\n", + }, + { + name: "can attest an artifact with --fingerprint", + cmd: fmt.Sprintf("attest artifact testdata/file1 --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name cli --git-commit HEAD --build-url example.com --commit-url example.com %s", suite.defaultKosliArguments), + golden: "artifact testdata/file1 was attested with fingerprint: 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestAttestArtifactCommandTestSuite(t *testing.T) { + suite.Run(t, new(AttestArtifactCommandTestSuite)) +} diff --git a/cmd/kosli/attestGeneric.go b/cmd/kosli/attestGeneric.go new file mode 100644 index 000000000..3ab3c5a20 --- /dev/null +++ b/cmd/kosli/attestGeneric.go @@ -0,0 +1,163 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "os" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +type GenericAttestationPayload struct { + *CommonAttestationPayload + Compliant bool `json:"is_compliant"` +} + +type attestGenericOptions struct { + *CommonAttestationOptions + payload GenericAttestationPayload +} + +const attestGenericShortDesc = `Report a generic attestation to an artifact or a trail in a Kosli flow. ` + +const attestGenericLongDesc = attestGenericShortDesc + ` +` + fingerprintDesc + +const attestGenericExample = ` +# report a generic attestation about a pre-built docker artifact (kosli calculates the fingerprint): +kosli attest generic yourDockerImageName \ + --artifact-type docker \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a generic attestation about a pre-built docker artifact (you provide the fingerprint): +kosli attest generic \ + --fingerprint yourDockerImageFingerprint \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a generic attestation about a trail: +kosli attest generic \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a generic attestation about an artifact which has not been reported yet in a trail: +kosli attest generic \ + --name yourTemplateArtifactName.yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a generic attestation about a trail with an evidence file: +kosli attest generic \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --evidence-paths=yourEvidencePathName \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a non-compliant generic attestation about a trail: +kosli attest generic \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --compliant=false \ + --api-token yourAPIToken \ + --org yourOrgName +` + +func newAttestGenericCmd(out io.Writer) *cobra.Command { + o := &attestGenericOptions{ + CommonAttestationOptions: &CommonAttestationOptions{ + fingerprintOptions: &fingerprintOptions{}, + }, + payload: GenericAttestationPayload{ + CommonAttestationPayload: &CommonAttestationPayload{}, + }, + } + cmd := &cobra.Command{ + Use: "generic [IMAGE-NAME | FILE-PATH | DIR-PATH]", + Short: attestGenericShortDesc, + Long: attestGenericLongDesc, + Example: attestGenericExample, + Args: cobra.MaximumNArgs(1), + Hidden: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + err = MuXRequiredFlags(cmd, []string{"fingerprint", "artifact-type"}, false) + if err != nil { + return err + } + + err = ValidateAttestationArtifactArg(args, o.fingerprintOptions.artifactType, o.payload.ArtifactFingerprint) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + return ValidateRegistryFlags(cmd, o.fingerprintOptions) + + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + ci := WhichCI() + addAttestationFlags(cmd, o.CommonAttestationOptions, o.payload.CommonAttestationPayload, ci) + cmd.Flags().BoolVarP(&o.payload.Compliant, "compliant", "C", true, attestationCompliantFlag) + + err := RequireFlags(cmd, []string{"flow", "trail", "name"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *attestGenericOptions) run(args []string) error { + url := fmt.Sprintf("%s/api/v2/attestations/%s/%s/trail/%s/generic", global.Host, global.Org, o.flowName, o.trailName) + + err := o.CommonAttestationOptions.run(args, o.payload.CommonAttestationPayload) + if err != nil { + return err + } + + form, cleanupNeeded, evidencePath, err := prepareAttestationForm(o.payload, o.evidencePaths) + if err != nil { + return err + } + // if we created a tar package, remove it after uploading it + if cleanupNeeded { + defer os.Remove(evidencePath) + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + Form: form, + DryRun: global.DryRun, + Password: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("generic attestation '%s' is reported to trail: %s", o.payload.AttestationName, o.trailName) + } + return err +} diff --git a/cmd/kosli/attestGeneric_test.go b/cmd/kosli/attestGeneric_test.go new file mode 100644 index 000000000..118e1bc7f --- /dev/null +++ b/cmd/kosli/attestGeneric_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type AttestGenericCommandTestSuite struct { + flowName string + trailName string + artifactFingerprint string + suite.Suite + defaultKosliArguments string +} + +func (suite *AttestGenericCommandTestSuite) SetupTest() { + suite.flowName = "attest-generic" + suite.trailName = "test-123" + suite.artifactFingerprint = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --flow %s --trail %s --repo-root ../.. --host %s --org %s --api-token %s", suite.flowName, suite.trailName, global.Host, global.Org, global.ApiToken) + CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) + BeginTrail(suite.trailName, suite.flowName, "", suite.T()) + CreateArtifactOnTrail(suite.flowName, suite.trailName, "cli", suite.artifactFingerprint, "file1", suite.T()) +} + +func (suite *AttestGenericCommandTestSuite) TestAttestGenericCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when more arguments are provided", + cmd: fmt.Sprintf("attest generic foo bar %s", suite.defaultKosliArguments), + golden: "Error: accepts at most 1 arg(s), received 2\n", + }, + { + wantError: true, + name: "fails when missing a required flag", + cmd: fmt.Sprintf("attest generic foo %s", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"name\" not set\n", + }, + { + wantError: true, + name: "fails when both --fingerprint and --artifact-type", + cmd: fmt.Sprintf("attest generic testdata/file1 --fingerprint xxxx --artifact-type file --name bar --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "Error: only one of --fingerprint, --artifact-type is allowed\n", + }, + { + wantError: true, + name: "fails when --fingerprint is not valid", + cmd: fmt.Sprintf("attest generic --name foo --fingerprint xxxx --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "Error: xxxx is not a valid SHA256 fingerprint. It should match the pattern ^([a-f0-9]{64})$\nUsage: kosli attest generic [IMAGE-NAME | FILE-PATH | DIR-PATH] [flags]\n", + }, + { + wantError: true, + name: "attesting against an artifact that does not exist fails", + cmd: fmt.Sprintf("attest generic --fingerprint 1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "Error: Artifact with fingerprint '1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9' does not exist in flow 'attest-generic' belonging to organization 'docs-cmd-test-user'\n", + }, + { + name: "can attest generic against an artifact using artifact name and --artifact-type", + cmd: fmt.Sprintf("attest generic testdata/file1 --artifact-type file --name foo --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "generic attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest generic against an artifact using artifact name and --artifact-type when --name does not exist in the trail template", + cmd: fmt.Sprintf("attest generic testdata/file1 --artifact-type file --name bar --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "generic attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest generic against an artifact using --fingerprint", + cmd: fmt.Sprintf("attest generic --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "generic attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest generic against a trail", + cmd: fmt.Sprintf("attest generic --name bar --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "generic attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest generic against a trail when name is not found in the trail template", + cmd: fmt.Sprintf("attest generic --name additional --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "generic attestation 'additional' is reported to trail: test-123\n", + }, + { + name: "can attest generic against an artifact it is created using dot syntax in --name", + cmd: fmt.Sprintf("attest generic --name cli.foo --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "generic attestation 'foo' is reported to trail: test-123\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestAttestGenericCommandTestSuite(t *testing.T) { + suite.Run(t, new(AttestGenericCommandTestSuite)) +} diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go new file mode 100644 index 000000000..9c570cd39 --- /dev/null +++ b/cmd/kosli/attestJira.go @@ -0,0 +1,258 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/kosli-dev/cli/internal/gitview" + "github.com/kosli-dev/cli/internal/jira" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +type JiraAttestationPayload struct { + *CommonAttestationPayload + JiraResults []*jira.JiraIssueInfo `json:"jira_results"` +} + +type attestJiraOptions struct { + *CommonAttestationOptions + baseURL string + username string + apiToken string + pat string + assert bool + payload JiraAttestationPayload +} + +const attestJiraShortDesc = `Report a jira attestation to an artifact or a trail in a Kosli flow. ` + +const attestJiraLongDesc = attestJiraShortDesc + ` +Parses the given commit's message or current branch name for Jira issue references of the +form: +'at least 2 characters long, starting with an uppercase letter project key followed by +dash and one or more digits'. + +The found issue references will be checked against Jira to confirm their existence. +The evidence is reported in all cases, and its compliance status depends on referencing +existing Jira issues. +If you have wrong Jira credentials or wrong Jira-base-url it will be reported as non existing Jira issue. +This is because Jira returns same 404 error code in all cases. +` + fingerprintDesc + +const attestJiraExample = ` +# report a jira attestation about a pre-built docker artifact (kosli calculates the fingerprint): +kosli attest jira yourDockerImageName \ + --artifact-type docker \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --jira-base-url https://kosli.atlassian.net \ + --jira-username user@domain.com \ + --jira-api-token yourJiraAPIToken \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a jira attestation about a pre-built docker artifact (you provide the fingerprint): +kosli attest jira \ + --fingerprint yourDockerImageFingerprint \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --jira-base-url https://kosli.atlassian.net \ + --jira-username user@domain.com \ + --jira-api-token yourJiraAPIToken \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a jira attestation about a trail: +kosli attest jira \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --jira-base-url https://kosli.atlassian.net \ + --jira-username user@domain.com \ + --jira-api-token yourJiraAPIToken \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a jira attestation about an artifact which has not been reported yet in a trail: +kosli attest jira \ + --name yourTemplateArtifactName.yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --jira-base-url https://kosli.atlassian.net \ + --jira-username user@domain.com \ + --jira-api-token yourJiraAPIToken \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a jira attestation about a trail with an evidence file: +kosli attest jira \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --jira-base-url https://kosli.atlassian.net \ + --jira-username user@domain.com \ + --jira-api-token yourJiraAPIToken \ + --evidence-paths=yourEvidencePathName \ + --api-token yourAPIToken \ + --org yourOrgName + +# fail if no issue reference is found, or the issue is not found in your jira instance +kosli attest jira \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --jira-base-url https://kosli.atlassian.net \ + --jira-username user@domain.com \ + --jira-api-token yourJiraAPIToken \ + --evidence-paths=yourEvidencePathName \ + --api-token yourAPIToken \ + --org yourOrgName \ + --assert +` + +func newAttestJiraCmd(out io.Writer) *cobra.Command { + o := &attestJiraOptions{ + CommonAttestationOptions: &CommonAttestationOptions{ + fingerprintOptions: &fingerprintOptions{}, + }, + payload: JiraAttestationPayload{ + CommonAttestationPayload: &CommonAttestationPayload{}, + }, + } + cmd := &cobra.Command{ + Use: "jira [IMAGE-NAME | FILE-PATH | DIR-PATH]", + Short: attestJiraShortDesc, + Long: attestJiraLongDesc, + Example: attestJiraExample, + Args: cobra.MaximumNArgs(1), + Hidden: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + err = MuXRequiredFlags(cmd, []string{"fingerprint", "artifact-type"}, false) + if err != nil { + return err + } + + err = MuXRequiredFlags(cmd, []string{"jira-pat", "jira-api-token"}, true) + if err != nil { + return err + } + + err = MuXRequiredFlags(cmd, []string{"jira-pat", "jira-username"}, true) + if err != nil { + return err + } + + err = ValidateAttestationArtifactArg(args, o.fingerprintOptions.artifactType, o.payload.ArtifactFingerprint) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + return ValidateRegistryFlags(cmd, o.fingerprintOptions) + + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + ci := WhichCI() + addAttestationFlags(cmd, o.CommonAttestationOptions, o.payload.CommonAttestationPayload, ci) + cmd.Flags().StringVar(&o.baseURL, "jira-base-url", "", jiraBaseUrlFlag) + cmd.Flags().StringVar(&o.username, "jira-username", "", jiraUsernameFlag) + cmd.Flags().StringVar(&o.apiToken, "jira-api-token", "", jiraAPITokenFlag) + cmd.Flags().StringVar(&o.pat, "jira-pat", "", jiraPATFlag) + cmd.Flags().BoolVar(&o.assert, "assert", false, attestationAssertFlag) + + err := RequireFlags(cmd, []string{"flow", "trail", "name", "commit", "jira-base-url"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *attestJiraOptions) run(args []string) error { + url := fmt.Sprintf("%s/api/v2/attestations/%s/%s/trail/%s/jira", global.Host, global.Org, o.flowName, o.trailName) + + err := o.CommonAttestationOptions.run(args, o.payload.CommonAttestationPayload) + if err != nil { + return err + } + + o.baseURL = strings.TrimSuffix(o.baseURL, "/") + jc := jira.NewJiraConfig(o.baseURL, o.username, o.apiToken, o.pat) + + o.payload.JiraResults = []*jira.JiraIssueInfo{} + + gv, err := gitview.New(o.srcRepoRoot) + if err != nil { + return err + } + // Jira issue keys consist of [project-key]-[sequential-number] + // project key must be at least 2 characters long and start with an uppercase letter + // more info: https://support.atlassian.com/jira-software-cloud/docs/what-is-an-issue/#Workingwithissues-Projectandissuekeys + jiraIssueKeyPattern := `[A-Z][A-Z0-9]{1,9}-[0-9]+` + + issueIDs, commitInfo, err := gv.MatchPatternInCommitMessageORBranchName(jiraIssueKeyPattern, o.payload.Commit.Sha1) + if err != nil { + return err + } + logger.Debug("Checked for Jira issue references in Git commit %s on branch %s commit message:\n%s", commitInfo.Sha1, commitInfo.Branch, commitInfo.Message) + logger.Debug("the following Jira references are found in commit message or branch name: %v", issueIDs) + + if len(issueIDs) == 0 && o.assert { + return fmt.Errorf("no Jira references are found in commit message or branch name") + } + + issueLog := "" + issueFoundCount := 0 + for _, issueID := range issueIDs { + result, err := jc.GetJiraIssueInfo(issueID) + if err != nil { + return err + } + o.payload.JiraResults = append(o.payload.JiraResults, result) + issueExistLog := "issue not found" + if result.IssueExists { + issueExistLog = "issue found" + issueFoundCount++ + } + issueLog += fmt.Sprintf("\n\t%s: %s", result.IssueID, issueExistLog) + } + if issueFoundCount != len(issueIDs) && o.assert { + return fmt.Errorf("missing Jira issues from references found in commit message or branch name%s", issueLog) + } + + form, cleanupNeeded, evidencePath, err := prepareAttestationForm(o.payload, o.evidencePaths) + if err != nil { + return err + } + // if we created a tar package, remove it after uploading it + if cleanupNeeded { + defer os.Remove(evidencePath) + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + Form: form, + DryRun: global.DryRun, + Password: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("jira attestation '%s' is reported to trail: %s", o.payload.AttestationName, o.trailName) + } + return err +} diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go new file mode 100644 index 000000000..eae68534b --- /dev/null +++ b/cmd/kosli/attestJira_test.go @@ -0,0 +1,213 @@ +package main + +import ( + "fmt" + "os" + "testing" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-git/v5" + "github.com/kosli-dev/cli/internal/testHelpers" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type AttestJiraCommandTestSuite struct { + suite.Suite + flowName string + trailName string + artifactFingerprint string + tmpDir string + workTree *git.Worktree + fs billy.Filesystem + defaultKosliArguments string +} + +func (suite *AttestJiraCommandTestSuite) SetupTest() { + testHelpers.SkipIfEnvVarUnset(suite.T(), []string{"KOSLI_JIRA_API_TOKEN"}) + suite.flowName = "attest-jira" + suite.trailName = "test-123" + suite.artifactFingerprint = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --flow %s --trail %s --host %s --org %s --api-token %s", suite.flowName, suite.trailName, global.Host, global.Org, global.ApiToken) + + var err error + suite.tmpDir, err = os.MkdirTemp("", "testDir") + require.NoError(suite.T(), err) + _, suite.workTree, suite.fs, err = testHelpers.InitializeGitRepo(suite.tmpDir) + require.NoError(suite.T(), err) + + CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) + BeginTrail(suite.trailName, suite.flowName, "", suite.T()) + CreateArtifactOnTrail(suite.flowName, suite.trailName, "cli", suite.artifactFingerprint, "file1", suite.T()) +} + +func (suite *AttestJiraCommandTestSuite) TearDownSuite() { + os.RemoveAll(suite.tmpDir) +} + +func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when more arguments are provided", + cmd: fmt.Sprintf("attest jira foo bar %s", suite.defaultKosliArguments), + golden: "Error: accepts at most 1 arg(s), received 2\n", + }, + { + wantError: true, + name: "fails when missing required flags", + cmd: fmt.Sprintf("attest jira foo --jira-username tore@kosli.com %s", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"commit\", \"jira-base-url\", \"name\" not set\n", + }, + { + wantError: true, + name: "fails when missing both --jira-username and --jira-pat flag", + cmd: fmt.Sprintf(`attest jira foo --name bar + --jira-base-url https://kosli-test.atlassian.net --jira-api-token xxx + %s`, suite.defaultKosliArguments), + golden: "Error: at least one of --jira-pat, --jira-username is required\n", + }, + { + wantError: true, + name: "fails when missing --commit flag", + cmd: fmt.Sprintf(`attest jira foo --name bar + --jira-base-url https://kosli-test.atlassian.net --jira-username tore@kosli.com + --jira-api-token secret + %s`, suite.defaultKosliArguments), + golden: "Error: required flag(s) \"commit\" not set\n", + }, + { + wantError: true, + name: "fails when both --fingerprint and --artifact-type", + cmd: fmt.Sprintf("attest jira testdata/file1 --fingerprint xxxx --artifact-type file --name bar --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "Error: only one of --fingerprint, --artifact-type is allowed\n", + }, + { + wantError: true, + name: "fails when --fingerprint is not valid", + cmd: fmt.Sprintf("attest jira --name foo --fingerprint xxxx --commit HEAD --url example.com --jira-username tore@kosli.com %s", suite.defaultKosliArguments), + golden: "Error: xxxx is not a valid SHA256 fingerprint. It should match the pattern ^([a-f0-9]{64})$\nUsage: kosli attest jira [IMAGE-NAME | FILE-PATH | DIR-PATH] [flags]\n", + }, + { + wantError: true, + name: "attesting against an artifact that does not exist fails", + cmd: fmt.Sprintf(`attest jira --fingerprint 1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 + --name foo + --repo-root %s + --jira-base-url https://kosli-test.atlassian.net + --jira-username tore@kosli.com %s`, suite.tmpDir, suite.defaultKosliArguments), + golden: "Error: Artifact with fingerprint '1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9' does not exist in flow 'attest-jira' belonging to organization 'docs-cmd-test-user'\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "test commit", + }, + }, + { + wantError: true, + name: "assert for non-existing Jira issue gives an error", + cmd: fmt.Sprintf(`attest jira --name jira-validation + --jira-base-url https://kosli-test.atlassian.net --jira-username tore@kosli.com + --repo-root %s + --assert %s`, suite.tmpDir, suite.defaultKosliArguments), + goldenRegex: "Error: missing Jira issues from references found in commit message or branch name.*", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "SAMI-1 test commit", + }, + }, + { + name: "can attest jira against an artifact using artifact name and --artifact-type", + cmd: fmt.Sprintf(`attest jira testdata/file1 --artifact-type file --name foo + --jira-base-url https://kosli-test.atlassian.net --jira-username tore@kosli.com + --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), + golden: "jira attestation 'foo' is reported to trail: test-123\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "EX-1 test commit", + }, + }, + { + name: "can attest jira against an artifact using artifact name and --artifact-type when --name does not exist in the trail template", + cmd: fmt.Sprintf(`attest jira testdata/file1 --artifact-type file --name bar + --jira-base-url https://kosli-test.atlassian.net --jira-username tore@kosli.com + --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), + golden: "jira attestation 'bar' is reported to trail: test-123\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "EX-1 test commit", + }, + }, + { + name: "can attest jira against an artifact using --fingerprint", + cmd: fmt.Sprintf(`attest jira --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo + --jira-base-url https://kosli-test.atlassian.net --jira-username tore@kosli.com + --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), + golden: "jira attestation 'foo' is reported to trail: test-123\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "EX-1 test commit", + }, + }, + { + name: "can attest jira against a trail", + cmd: fmt.Sprintf(`attest jira --name bar + --jira-base-url https://kosli-test.atlassian.net --jira-username tore@kosli.com + --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), + golden: "jira attestation 'bar' is reported to trail: test-123\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "EX-1 test commit", + }, + }, + { + name: "can attest jira against a trail when name is not found in the trail template", + cmd: fmt.Sprintf(`attest jira --name additional + --jira-base-url https://kosli-test.atlassian.net --jira-username tore@kosli.com + --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), + golden: "jira attestation 'additional' is reported to trail: test-123\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "EX-1 test commit", + }, + }, + { + name: "can attest jira against an artifact it is created using dot syntax in --name", + cmd: fmt.Sprintf(`attest jira --name cli.foo + --jira-base-url https://kosli-test.atlassian.net --jira-username tore@kosli.com + --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), + golden: "jira attestation 'foo' is reported to trail: test-123\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "EX-1 test commit", + }, + }, + } + + for _, test := range tests { + execJiraTestCase(test, suite) + } +} + +func execJiraTestCase(test cmdTestCase, suite *AttestJiraCommandTestSuite) { + if test.additionalConfig != nil { + branchName := test.additionalConfig.(jiraTestsAdditionalConfig).branchName + if branchName != "" { + err := testHelpers.CheckoutNewBranch(suite.workTree, branchName) + require.NoError(suite.T(), err) + defer testHelpers.CheckoutMaster(suite.workTree, suite.T()) + } + msg := test.additionalConfig.(jiraTestsAdditionalConfig).commitMessage + commitSha, err := testHelpers.CommitToRepo(suite.workTree, suite.fs, msg) + require.NoError(suite.T(), err) + + test.cmd = test.cmd + " --commit " + commitSha + } + + runTestCmd(suite.T(), []cmdTestCase{test}) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestAttestJiraCommandTestSuite(t *testing.T) { + suite.Run(t, new(AttestJiraCommandTestSuite)) +} diff --git a/cmd/kosli/attestJunit.go b/cmd/kosli/attestJunit.go new file mode 100644 index 000000000..f7f38a505 --- /dev/null +++ b/cmd/kosli/attestJunit.go @@ -0,0 +1,176 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "os" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +type JunitAttestationPayload struct { + *CommonAttestationPayload + JUnitResults []*JUnitResults `json:"junit_results"` +} + +type attestJunitOptions struct { + *CommonAttestationOptions + testResultsDir string + uploadResultsDir bool + payload JunitAttestationPayload +} + +const attestJunitShortDesc = `Report a junit attestation to an artifact or a trail in a Kosli flow. ` + +const attestJunitLongDesc = attestJunitShortDesc + ` +` + fingerprintDesc + +const attestJunitExample = ` +# report a junit attestation about a pre-built docker artifact (kosli calculates the fingerprint): +kosli attest junit yourDockerImageName \ + --artifact-type docker \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --results-dir yourFolderWithJUnitResults \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a junit attestation about a pre-built docker artifact (you provide the fingerprint): +kosli attest junit \ + --fingerprint yourDockerImageFingerprint \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --results-dir yourFolderWithJUnitResults \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a junit attestation about a trail: +kosli attest junit \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --results-dir yourFolderWithJUnitResults \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a junit attestation about an artifact which has not been reported yet in a trail: +kosli attest junit \ + --name yourTemplateArtifactName.yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --results-dir yourFolderWithJUnitResults \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a junit attestation about a trail with an evidence file: +kosli attest junit \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --results-dir yourFolderWithJUnitResults \ + --evidence-paths=yourEvidencePathName \ + --api-token yourAPIToken \ + --org yourOrgName +` + +func newAttestJunitCmd(out io.Writer) *cobra.Command { + o := &attestJunitOptions{ + CommonAttestationOptions: &CommonAttestationOptions{ + fingerprintOptions: &fingerprintOptions{}, + }, + payload: JunitAttestationPayload{ + CommonAttestationPayload: &CommonAttestationPayload{}, + }, + } + cmd := &cobra.Command{ + Use: "junit [IMAGE-NAME | FILE-PATH | DIR-PATH]", + Short: attestJunitShortDesc, + Long: attestJunitLongDesc, + Example: attestJunitExample, + Args: cobra.MaximumNArgs(1), + Hidden: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + err = MuXRequiredFlags(cmd, []string{"fingerprint", "artifact-type"}, false) + if err != nil { + return err + } + + err = ValidateAttestationArtifactArg(args, o.fingerprintOptions.artifactType, o.payload.ArtifactFingerprint) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + return ValidateRegistryFlags(cmd, o.fingerprintOptions) + + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + ci := WhichCI() + addAttestationFlags(cmd, o.CommonAttestationOptions, o.payload.CommonAttestationPayload, ci) + cmd.Flags().StringVarP(&o.testResultsDir, "results-dir", "R", ".", resultsDirFlag) + cmd.Flags().BoolVar(&o.uploadResultsDir, "upload-results", true, uploadJunitResultsFlag) + + err := RequireFlags(cmd, []string{"flow", "trail", "name"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *attestJunitOptions) run(args []string) error { + url := fmt.Sprintf("%s/api/v2/attestations/%s/%s/trail/%s/junit", global.Host, global.Org, o.flowName, o.trailName) + + err := o.CommonAttestationOptions.run(args, o.payload.CommonAttestationPayload) + if err != nil { + return err + } + + o.payload.JUnitResults, err = ingestJunitDir(o.testResultsDir) + if err != nil { + return err + } + + if o.uploadResultsDir { + // prepare the files to upload as evidence. We are only interested in the actual Junit XMl files + junitFilenames, err := getJunitFilenames(o.testResultsDir) + if err != nil { + return err + } + o.evidencePaths = append(o.evidencePaths, junitFilenames...) + } + + form, cleanupNeeded, evidencePath, err := prepareAttestationForm(o.payload, o.evidencePaths) + if err != nil { + return err + } + // if we created a tar package, remove it after uploading it + if cleanupNeeded { + defer os.Remove(evidencePath) + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + Form: form, + DryRun: global.DryRun, + Password: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("junit attestation '%s' is reported to trail: %s", o.payload.AttestationName, o.trailName) + } + return err +} diff --git a/cmd/kosli/attestJunit_test.go b/cmd/kosli/attestJunit_test.go new file mode 100644 index 000000000..59952ffde --- /dev/null +++ b/cmd/kosli/attestJunit_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type AttestJunitCommandTestSuite struct { + flowName string + trailName string + artifactFingerprint string + suite.Suite + defaultKosliArguments string +} + +func (suite *AttestJunitCommandTestSuite) SetupTest() { + suite.flowName = "attest-junit" + suite.trailName = "test-123" + suite.artifactFingerprint = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --flow %s --trail %s --repo-root ../.. --host %s --org %s --api-token %s", suite.flowName, suite.trailName, global.Host, global.Org, global.ApiToken) + CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) + BeginTrail(suite.trailName, suite.flowName, "", suite.T()) + CreateArtifactOnTrail(suite.flowName, suite.trailName, "cli", suite.artifactFingerprint, "file1", suite.T()) +} + +func (suite *AttestJunitCommandTestSuite) TestAttestJunitCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when more arguments are provided", + cmd: fmt.Sprintf("attest junit foo bar %s", suite.defaultKosliArguments), + golden: "Error: accepts at most 1 arg(s), received 2\n", + }, + { + wantError: true, + name: "fails when missing a required flags", + cmd: fmt.Sprintf("attest junit foo %s", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"name\" not set\n", + }, + { + wantError: true, + name: "fails when both --fingerprint and --artifact-type", + cmd: fmt.Sprintf("attest junit testdata/file1 --fingerprint xxxx --artifact-type file --name bar --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "Error: only one of --fingerprint, --artifact-type is allowed\n", + }, + { + wantError: true, + name: "fails when --fingerprint is not valid", + cmd: fmt.Sprintf("attest junit --name foo --fingerprint xxxx --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "Error: xxxx is not a valid SHA256 fingerprint. It should match the pattern ^([a-f0-9]{64})$\nUsage: kosli attest junit [IMAGE-NAME | FILE-PATH | DIR-PATH] [flags]\n", + }, + { + wantError: true, + name: "attesting against an artifact that does not exist fails", + cmd: fmt.Sprintf("attest junit --fingerprint 1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo --commit HEAD --url example.com --results-dir testdata %s", suite.defaultKosliArguments), + golden: "Error: Artifact with fingerprint '1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9' does not exist in flow 'attest-junit' belonging to organization 'docs-cmd-test-user'\n", + }, + { + name: "can attest junit against an artifact using artifact name and --artifact-type", + cmd: fmt.Sprintf("attest junit testdata/file1 --artifact-type file --name foo --commit HEAD --url example.com --results-dir testdata %s", suite.defaultKosliArguments), + golden: "junit attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest junit against an artifact using artifact name and --artifact-type when --name does not exist in the trail template", + cmd: fmt.Sprintf("attest junit testdata/file1 --artifact-type file --name bar --commit HEAD --url example.com --results-dir testdata %s", suite.defaultKosliArguments), + golden: "junit attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest junit against an artifact using --fingerprint", + cmd: fmt.Sprintf("attest junit --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo --commit HEAD --url example.com --results-dir testdata %s", suite.defaultKosliArguments), + golden: "junit attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest junit against a trail", + cmd: fmt.Sprintf("attest junit --name bar --commit HEAD --url example.com --results-dir testdata %s", suite.defaultKosliArguments), + golden: "junit attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest junit against a trail when name is not found in the trail template", + cmd: fmt.Sprintf("attest junit --name additional --commit HEAD --url example.com --results-dir testdata %s", suite.defaultKosliArguments), + golden: "junit attestation 'additional' is reported to trail: test-123\n", + }, + { + name: "can attest junit against an artifact it is created using dot syntax in --name", + cmd: fmt.Sprintf("attest junit --name cli.foo --commit HEAD --url example.com --results-dir testdata %s", suite.defaultKosliArguments), + golden: "junit attestation 'foo' is reported to trail: test-123\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestAttestJunitCommandTestSuite(t *testing.T) { + suite.Run(t, new(AttestJunitCommandTestSuite)) +} diff --git a/cmd/kosli/attestPR.go b/cmd/kosli/attestPR.go new file mode 100644 index 000000000..241968224 --- /dev/null +++ b/cmd/kosli/attestPR.go @@ -0,0 +1,29 @@ +package main + +import ( + "io" + + "github.com/spf13/cobra" +) + +const attestPRDesc = `All Kosli commands to attest pull/merge request.` + +func newAttestPRCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "pullrequest", + Aliases: []string{"pr", "mr", "mergerequest"}, + Short: attestPRDesc, + Long: attestPRDesc, + Hidden: true, + } + + // Add subcommands + cmd.AddCommand( + newAttestGitlabPRCmd(out), + newAttestGithubPRCmd(out), + newAttestBitbucketPRCmd(out), + newAttestAzurePRCmd(out), + ) + + return cmd +} diff --git a/cmd/kosli/attestPRAzure.go b/cmd/kosli/attestPRAzure.go new file mode 100644 index 000000000..be3209253 --- /dev/null +++ b/cmd/kosli/attestPRAzure.go @@ -0,0 +1,155 @@ +package main + +import ( + "io" + + "github.com/kosli-dev/cli/internal/azure" + "github.com/spf13/cobra" +) + +const attestPRAzureShortDesc = `Report an Azure Devops pull request attestation to an artifact or a trail in a Kosli flow. ` + +const attestPRAzureLongDesc = attestPRAzureShortDesc + ` +It checks if a pull request exists for the artifact (based on its git commit) and reports the pull-request evidence to the artifact in Kosli. +` + fingerprintDesc + +const attestPRAzureExample = ` +# report an Azure Devops pull request attestation about a pre-built docker artifact (kosli calculates the fingerprint): +kosli attest pullrequest azure yourDockerImageName \ + --artifact-type docker \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --azure-org-url https://dev.azure.com/myOrg \ + --project yourAzureDevOpsProject \ + --azure-token yourAzureToken \ + --commit yourGitCommitSha1 \ + --repository yourAzureGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report an Azure Devops pull request attestation about a pre-built docker artifact (you provide the fingerprint): +kosli attest pullrequest azure \ + --fingerprint yourDockerImageFingerprint \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --azure-org-url https://dev.azure.com/myOrg \ + --project yourAzureDevOpsProject \ + --azure-token yourAzureToken \ + --commit yourGitCommitSha1 \ + --repository yourAzureGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report an Azure Devops pull request attestation about a trail: +kosli attest pullrequest azure \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --azure-org-url https://dev.azure.com/myOrg \ + --project yourAzureDevOpsProject \ + --azure-token yourAzureToken \ + --commit yourGitCommitSha1 \ + --repository yourAzureGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report an Azure Devops pull request attestation about an artifact which has not been reported yet in a trail: +kosli attest pullrequest azure \ + --name yourTemplateArtifactName.yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --azure-org-url https://dev.azure.com/myOrg \ + --project yourAzureDevOpsProject \ + --azure-token yourAzureToken \ + --commit yourGitCommitSha1 \ + --repository yourAzureGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report an Azure Devops pull request attestation about a trail with an evidence file: +kosli attest pullrequest azure \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --azure-org-url https://dev.azure.com/myOrg \ + --project yourAzureDevOpsProject \ + --azure-token yourAzureToken \ + --commit yourGitCommitSha1 \ + --repository yourAzureGitRepository \ + --evidence-paths=yourEvidencePathName \ + --api-token yourAPIToken \ + --org yourOrgName + +# fail if a pull request does not exist for your artifact +kosli attest pullrequest azure \ + --name yourTemplateArtifactName.yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --azure-org-url https://dev.azure.com/myOrg \ + --project yourAzureDevOpsProject \ + --azure-token yourAzureToken \ + --commit yourGitCommitSha1 \ + --repository yourAzureGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName \ + --assert +` + +func newAttestAzurePRCmd(out io.Writer) *cobra.Command { + o := &attestPROptions{ + CommonAttestationOptions: &CommonAttestationOptions{ + fingerprintOptions: &fingerprintOptions{}, + }, + payload: PRAttestationPayload{ + CommonAttestationPayload: &CommonAttestationPayload{}, + }, + retriever: new(azure.AzureConfig), + } + cmd := &cobra.Command{ + Use: "azure [IMAGE-NAME | FILE-PATH | DIR-PATH]", + Aliases: []string{"az"}, + Short: attestPRAzureShortDesc, + Long: attestPRAzureLongDesc, + Example: attestPRAzureExample, + Args: cobra.MaximumNArgs(1), + Hidden: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + err = MuXRequiredFlags(cmd, []string{"fingerprint", "artifact-type"}, false) + if err != nil { + return err + } + + err = ValidateAttestationArtifactArg(args, o.fingerprintOptions.artifactType, o.payload.ArtifactFingerprint) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + return ValidateRegistryFlags(cmd, o.fingerprintOptions) + + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + ci := WhichCI() + addAttestationFlags(cmd, o.CommonAttestationOptions, o.payload.CommonAttestationPayload, ci) + addAttestationAzureFlags(cmd, o.getRetriever().(*azure.AzureConfig), ci) + cmd.Flags().BoolVar(&o.assert, "assert", false, assertPREvidenceFlag) + + err := RequireFlags(cmd, []string{"flow", "trail", "name", + "azure-token", "azure-org-url", + "project", "commit", "repository"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} diff --git a/cmd/kosli/attestPRAzure_test.go b/cmd/kosli/attestPRAzure_test.go new file mode 100644 index 000000000..f3ffc09f5 --- /dev/null +++ b/cmd/kosli/attestPRAzure_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/kosli-dev/cli/internal/testHelpers" + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type AttestAzurePRCommandTestSuite struct { + flowName string + trailName string + artifactFingerprint string + suite.Suite + defaultKosliArguments string +} + +func (suite *AttestAzurePRCommandTestSuite) SetupTest() { + testHelpers.SkipIfEnvVarUnset(suite.T(), []string{"KOSLI_AZURE_TOKEN"}) + + suite.flowName = "attest-azure-pr" + suite.trailName = "test-123" + suite.artifactFingerprint = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --flow %s --trail %s --repo-root ../.. --host %s --org %s --api-token %s", suite.flowName, suite.trailName, global.Host, global.Org, global.ApiToken) + CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) + BeginTrail(suite.trailName, suite.flowName, "", suite.T()) + CreateArtifactOnTrail(suite.flowName, suite.trailName, "cli", suite.artifactFingerprint, "file1", suite.T()) +} + +func (suite *AttestAzurePRCommandTestSuite) TestAttestAzurePRCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when more arguments are provided", + cmd: fmt.Sprintf("attest pullrequest azure foo bar %s", suite.defaultKosliArguments), + golden: "Error: accepts at most 1 arg(s), received 2\n", + }, + { + wantError: true, + name: "fails when missing a required flags", + cmd: fmt.Sprintf("attest pullrequest azure foo %s", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"azure-org-url\", \"commit\", \"name\", \"project\", \"repository\" not set\n", + }, + { + wantError: true, + name: "fails when both --fingerprint and --artifact-type", + cmd: fmt.Sprintf("attest pullrequest azure testdata/file1 --fingerprint xxxx --artifact-type file --name bar --commit HEAD %s", suite.defaultKosliArguments), + golden: "Error: only one of --fingerprint, --artifact-type is allowed\n", + }, + { + wantError: true, + name: "fails when --fingerprint is not valid", + cmd: fmt.Sprintf("attest pullrequest azure --name foo --fingerprint xxxx --commit HEAD %s", suite.defaultKosliArguments), + golden: "Error: xxxx is not a valid SHA256 fingerprint. It should match the pattern ^([a-f0-9]{64})$\nUsage: kosli attest pullrequest azure [IMAGE-NAME | FILE-PATH | DIR-PATH] [flags]\n", + }, + { + wantError: true, + name: "attesting against an artifact that does not exist fails", + cmd: fmt.Sprintf(`attest pullrequest azure --fingerprint 1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo + --azure-org-url https://dev.azure.com/kosli --project kosli-azure --repository cli --commit HEAD %s`, suite.defaultKosliArguments), + goldenRegex: "no pull requests found for given commit: .*\nError: Artifact with fingerprint '1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9' does not exist in flow 'attest-azure-pr' belonging to organization 'docs-cmd-test-user'\n", + }, + { + name: "can attest azure pr against an artifact using artifact name and --artifact-type", + cmd: fmt.Sprintf(`attest pullrequest azure testdata/file1 --artifact-type file --name foo + --azure-org-url https://dev.azure.com/kosli --project kosli-azure --repository cli --commit HEAD %s`, suite.defaultKosliArguments), + goldenRegex: "no pull requests found for given commit: .*\nazure pull request attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest azure pr against an artifact using artifact name and --artifact-type when --name does not exist in the trail template", + cmd: fmt.Sprintf(`attest pullrequest azure testdata/file1 --artifact-type file --name bar + --azure-org-url https://dev.azure.com/kosli --project kosli-azure --repository cli --commit HEAD %s`, suite.defaultKosliArguments), + goldenRegex: "no pull requests found for given commit: .*\nazure pull request attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest azure pr against an artifact using --fingerprint", + cmd: fmt.Sprintf(`attest pullrequest azure --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo + --azure-org-url https://dev.azure.com/kosli --project kosli-azure --repository cli --commit HEAD %s`, suite.defaultKosliArguments), + goldenRegex: "no pull requests found for given commit: .*\nazure pull request attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest azure pr against a trail", + cmd: fmt.Sprintf(`attest pullrequest azure --name bar + --azure-org-url https://dev.azure.com/kosli --project kosli-azure --repository cli --commit HEAD %s`, suite.defaultKosliArguments), + goldenRegex: "no pull requests found for given commit: .*\nazure pull request attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest azure pr against a trail when name is not found in the trail template", + cmd: fmt.Sprintf(`attest pullrequest azure --name additional + --azure-org-url https://dev.azure.com/kosli --project kosli-azure --repository cli --commit HEAD %s`, suite.defaultKosliArguments), + goldenRegex: "no pull requests found for given commit: .*\nazure pull request attestation 'additional' is reported to trail: test-123\n", + }, + { + name: "can attest azure pr against an artifact it is created using dot syntax in --name", + cmd: fmt.Sprintf(`attest pullrequest azure --name cli.foo + --azure-org-url https://dev.azure.com/kosli --project kosli-azure --repository cli --commit HEAD %s`, suite.defaultKosliArguments), + goldenRegex: "no pull requests found for given commit: .*\nazure pull request attestation 'foo' is reported to trail: test-123\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestAttestAzurePRCommandTestSuite(t *testing.T) { + suite.Run(t, new(AttestAzurePRCommandTestSuite)) +} diff --git a/cmd/kosli/attestPRBitbucket.go b/cmd/kosli/attestPRBitbucket.go new file mode 100644 index 000000000..7dd11bc45 --- /dev/null +++ b/cmd/kosli/attestPRBitbucket.go @@ -0,0 +1,159 @@ +package main + +import ( + "io" + + bbUtils "github.com/kosli-dev/cli/internal/bitbucket" + "github.com/spf13/cobra" +) + +const attestPRBitbucketShortDesc = `Report a Bitbucket pull request attestation to an artifact or a trail in a Kosli flow. ` + +const attestPRBitbucketLongDesc = attestPRBitbucketShortDesc + ` +It checks if a pull request exists for the artifact (based on its git commit) and reports the pull-request evidence to the artifact in Kosli. +` + fingerprintDesc + +const attestPRBitbucketExample = ` +# report a Bitbucket pull request attestation about a pre-built docker artifact (kosli calculates the fingerprint): +kosli attest pullrequest bitbucket yourDockerImageName \ + --artifact-type docker \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --bitbucket-username yourBitbucketUsername \ + --bitbucket-password yourBitbucketPassword \ + --bitbucket-workspace yourBitbucketWorkspace \ + --commit yourArtifactGitCommit \ + --repository yourBitbucketGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a Bitbucket pull request attestation about a pre-built docker artifact (you provide the fingerprint): +kosli attest pullrequest bitbucket \ + --fingerprint yourDockerImageFingerprint \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --bitbucket-username yourBitbucketUsername \ + --bitbucket-password yourBitbucketPassword \ + --bitbucket-workspace yourBitbucketWorkspace \ + --commit yourArtifactGitCommit \ + --repository yourBitbucketGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a Bitbucket pull request attestation about a trail: +kosli attest pullrequest bitbucket \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --bitbucket-username yourBitbucketUsername \ + --bitbucket-password yourBitbucketPassword \ + --bitbucket-workspace yourBitbucketWorkspace \ + --commit yourArtifactGitCommit \ + --repository yourBitbucketGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a Bitbucket pull request attestation about an artifact which has not been reported yet in a trail: +kosli attest pullrequest bitbucket \ + --name yourTemplateArtifactName.yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --bitbucket-username yourBitbucketUsername \ + --bitbucket-password yourBitbucketPassword \ + --bitbucket-workspace yourBitbucketWorkspace \ + --commit yourArtifactGitCommit \ + --repository yourBitbucketGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a Bitbucket pull request attestation about a trail with an evidence file: +kosli attest pullrequest bitbucket \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --bitbucket-username yourBitbucketUsername \ + --bitbucket-password yourBitbucketPassword \ + --bitbucket-workspace yourBitbucketWorkspace \ + --commit yourArtifactGitCommit \ + --repository yourBitbucketGitRepository \ + --evidence-paths=yourEvidencePathName \ + --api-token yourAPIToken \ + --org yourOrgName + +# fail if a pull request does not exist for your artifact +kosli attest pullrequest bitbucket \ + --name yourTemplateArtifactName.yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --bitbucket-username yourBitbucketUsername \ + --bitbucket-password yourBitbucketPassword \ + --bitbucket-workspace yourBitbucketWorkspace \ + --commit yourArtifactGitCommit \ + --repository yourBitbucketGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName \ + --assert +` + +func newAttestBitbucketPRCmd(out io.Writer) *cobra.Command { + config := new(bbUtils.Config) + config.Logger = logger + config.KosliClient = kosliClient + + o := &attestPROptions{ + CommonAttestationOptions: &CommonAttestationOptions{ + fingerprintOptions: &fingerprintOptions{}, + }, + payload: PRAttestationPayload{ + CommonAttestationPayload: &CommonAttestationPayload{}, + }, + retriever: config, + } + cmd := &cobra.Command{ + Use: "bitbucket [IMAGE-NAME | FILE-PATH | DIR-PATH]", + Aliases: []string{"bb"}, + Short: attestPRBitbucketShortDesc, + Long: attestPRBitbucketLongDesc, + Example: attestPRBitbucketExample, + Args: cobra.MaximumNArgs(1), + Hidden: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + err = MuXRequiredFlags(cmd, []string{"fingerprint", "artifact-type"}, false) + if err != nil { + return err + } + + err = ValidateAttestationArtifactArg(args, o.fingerprintOptions.artifactType, o.payload.ArtifactFingerprint) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + return ValidateRegistryFlags(cmd, o.fingerprintOptions) + + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + ci := WhichCI() + addAttestationFlags(cmd, o.CommonAttestationOptions, o.payload.CommonAttestationPayload, ci) + addBitbucketFlags(cmd, o.getRetriever().(*bbUtils.Config), ci) + cmd.Flags().BoolVar(&o.assert, "assert", false, assertPREvidenceFlag) + + err := RequireFlags(cmd, []string{"flow", "trail", "name", + "bitbucket-username", "bitbucket-password", + "bitbucket-workspace", "commit", "repository"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} diff --git a/cmd/kosli/attestPRBitbucket_test.go b/cmd/kosli/attestPRBitbucket_test.go new file mode 100644 index 000000000..5e4c7c65b --- /dev/null +++ b/cmd/kosli/attestPRBitbucket_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "fmt" + "os" + "testing" + + "github.com/kosli-dev/cli/internal/testHelpers" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type AttestBitbucketPRCommandTestSuite struct { + flowName string + trailName string + tmpDir string + artifactFingerprint string + suite.Suite + defaultKosliArguments string +} + +func (suite *AttestBitbucketPRCommandTestSuite) SetupTest() { + testHelpers.SkipIfEnvVarUnset(suite.T(), []string{"KOSLI_BITBUCKET_PASSWORD"}) + + suite.flowName = "attest-bitbucket-pr" + suite.trailName = "test-123" + suite.artifactFingerprint = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + + var err error + suite.tmpDir, err = os.MkdirTemp("", "testDir") + require.NoError(suite.T(), err) + _, err = testHelpers.CloneGitRepo("https://bitbucket.org/ewelinawilkosz/cli-test.git", suite.tmpDir) + require.NoError(suite.T(), err) + + suite.defaultKosliArguments = fmt.Sprintf(" --flow %s --trail %s --repo-root %s --commit 2492011ef04a9da09d35be706cf6a4c5bc6f1e69 --host %s --org %s --api-token %s", suite.flowName, suite.trailName, suite.tmpDir, global.Host, global.Org, global.ApiToken) + CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) + BeginTrail(suite.trailName, suite.flowName, "", suite.T()) + CreateArtifactOnTrail(suite.flowName, suite.trailName, "cli", suite.artifactFingerprint, "file1", suite.T()) +} + +func (suite *AttestBitbucketPRCommandTestSuite) TearDownSuite() { + os.RemoveAll(suite.tmpDir) +} + +func (suite *AttestBitbucketPRCommandTestSuite) TestAttestBitbucketPRCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when more arguments are provided", + cmd: fmt.Sprintf("attest pullrequest bitbucket foo bar %s", suite.defaultKosliArguments), + golden: "Error: accepts at most 1 arg(s), received 2\n", + }, + { + wantError: true, + name: "fails when missing a required flags", + cmd: fmt.Sprintf("attest pullrequest bitbucket foo %s", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"bitbucket-username\", \"bitbucket-workspace\", \"name\", \"repository\" not set\n", + }, + { + wantError: true, + name: "fails when both --fingerprint and --artifact-type", + cmd: fmt.Sprintf("attest pullrequest bitbucket testdata/file1 --fingerprint xxxx --artifact-type file --name bar %s", suite.defaultKosliArguments), + golden: "Error: only one of --fingerprint, --artifact-type is allowed\n", + }, + { + wantError: true, + name: "fails when --fingerprint is not valid", + cmd: fmt.Sprintf("attest pullrequest bitbucket --name foo --fingerprint xxxx %s", suite.defaultKosliArguments), + golden: "Error: xxxx is not a valid SHA256 fingerprint. It should match the pattern ^([a-f0-9]{64})$\nUsage: kosli attest pullrequest bitbucket [IMAGE-NAME | FILE-PATH | DIR-PATH] [flags]\n", + }, + { + wantError: true, + name: "attesting against an artifact that does not exist fails", + cmd: fmt.Sprintf(`attest pullrequest bitbucket --fingerprint 1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo + --bitbucket-username ewelinawilkosz --bitbucket-workspace ewelinawilkosz --repository cli-test %s`, suite.defaultKosliArguments), + golden: "Error: Artifact with fingerprint '1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9' does not exist in flow 'attest-bitbucket-pr' belonging to organization 'docs-cmd-test-user'\n", + }, + { + name: "can attest bitbucket pr against an artifact using artifact name and --artifact-type", + cmd: fmt.Sprintf(`attest pullrequest bitbucket testdata/file1 --artifact-type file --name foo + --bitbucket-username ewelinawilkosz --bitbucket-workspace ewelinawilkosz --repository cli-test %s`, suite.defaultKosliArguments), + golden: "bitbucket pull request attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest bitbucket pr against an artifact using artifact name and --artifact-type when --name does not exist in the trail template", + cmd: fmt.Sprintf(`attest pullrequest bitbucket testdata/file1 --artifact-type file --name bar + --bitbucket-username ewelinawilkosz --bitbucket-workspace ewelinawilkosz --repository cli-test %s`, suite.defaultKosliArguments), + golden: "bitbucket pull request attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest bitbucket pr against an artifact using --fingerprint", + cmd: fmt.Sprintf(`attest pullrequest bitbucket --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo + --bitbucket-username ewelinawilkosz --bitbucket-workspace ewelinawilkosz --repository cli-test %s`, suite.defaultKosliArguments), + golden: "bitbucket pull request attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest bitbucket pr against a trail", + cmd: fmt.Sprintf(`attest pullrequest bitbucket --name bar + --bitbucket-username ewelinawilkosz --bitbucket-workspace ewelinawilkosz --repository cli-test %s`, suite.defaultKosliArguments), + golden: "bitbucket pull request attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest bitbucket pr against a trail when name is not found in the trail template", + cmd: fmt.Sprintf(`attest pullrequest bitbucket --name additional + --bitbucket-username ewelinawilkosz --bitbucket-workspace ewelinawilkosz --repository cli-test %s`, suite.defaultKosliArguments), + golden: "bitbucket pull request attestation 'additional' is reported to trail: test-123\n", + }, + { + name: "can attest bitbucket pr against an artifact it is created using dot syntax in --name", + cmd: fmt.Sprintf(`attest pullrequest bitbucket --name cli.foo + --bitbucket-username ewelinawilkosz --bitbucket-workspace ewelinawilkosz --repository cli-test %s`, suite.defaultKosliArguments), + golden: "bitbucket pull request attestation 'foo' is reported to trail: test-123\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestAttestBitbucketPRCommandTestSuite(t *testing.T) { + suite.Run(t, new(AttestBitbucketPRCommandTestSuite)) +} diff --git a/cmd/kosli/attestPRGithub.go b/cmd/kosli/attestPRGithub.go new file mode 100644 index 000000000..530c7583c --- /dev/null +++ b/cmd/kosli/attestPRGithub.go @@ -0,0 +1,148 @@ +package main + +import ( + "io" + + ghUtils "github.com/kosli-dev/cli/internal/github" + "github.com/spf13/cobra" +) + +const attestPRGithubShortDesc = `Report a Github pull request attestation to an artifact or a trail in a Kosli flow. ` + +const attestPRGithubLongDesc = attestPRGithubShortDesc + ` +It checks if a pull request exists for the artifact (based on its git commit) and reports the pull-request evidence to the artifact in Kosli. +` + fingerprintDesc + +const attestPRGithubExample = ` +# report a Github pull request attestation about a pre-built docker artifact (kosli calculates the fingerprint): +kosli attest pullrequest github yourDockerImageName \ + --artifact-type docker \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --github-token yourGithubToken \ + --github-org yourGithubOrg \ + --commit yourArtifactGitCommit \ + --repository yourGithubGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a Github pull request attestation about a pre-built docker artifact (you provide the fingerprint): +kosli attest pullrequest github \ + --fingerprint yourDockerImageFingerprint \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --github-token yourGithubToken \ + --github-org yourGithubOrg \ + --commit yourArtifactGitCommit \ + --repository yourGithubGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a Github pull request attestation about a trail: +kosli attest pullrequest github \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --github-token yourGithubToken \ + --github-org yourGithubOrg \ + --commit yourArtifactGitCommit \ + --repository yourGithubGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a Github pull request attestation about an artifact which has not been reported yet in a trail: +kosli attest pullrequest github \ + --name yourTemplateArtifactName.yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --github-token yourGithubToken \ + --github-org yourGithubOrg \ + --commit yourArtifactGitCommit \ + --repository yourGithubGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a Github pull request attestation about a trail with an evidence file: +kosli attest pullrequest github \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --github-token yourGithubToken \ + --github-org yourGithubOrg \ + --commit yourArtifactGitCommit \ + --repository yourGithubGitRepository \ + --evidence-paths=yourEvidencePathName \ + --api-token yourAPIToken \ + --org yourOrgName + +# fail if a pull request does not exist for your artifact +kosli attest pullrequest github \ + --name yourTemplateArtifactName.yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --github-token yourGithubToken \ + --github-org yourGithubOrg \ + --commit yourArtifactGitCommit \ + --repository yourGithubGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName \ + --assert +` + +func newAttestGithubPRCmd(out io.Writer) *cobra.Command { + o := &attestPROptions{ + CommonAttestationOptions: &CommonAttestationOptions{ + fingerprintOptions: &fingerprintOptions{}, + }, + payload: PRAttestationPayload{ + CommonAttestationPayload: &CommonAttestationPayload{}, + }, + retriever: new(ghUtils.GithubConfig), + } + cmd := &cobra.Command{ + Use: "github [IMAGE-NAME | FILE-PATH | DIR-PATH]", + Aliases: []string{"gh"}, + Short: attestPRGithubShortDesc, + Long: attestPRGithubLongDesc, + Example: attestPRGithubExample, + Args: cobra.MaximumNArgs(1), + Hidden: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + err = MuXRequiredFlags(cmd, []string{"fingerprint", "artifact-type"}, false) + if err != nil { + return err + } + + err = ValidateAttestationArtifactArg(args, o.fingerprintOptions.artifactType, o.payload.ArtifactFingerprint) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + return ValidateRegistryFlags(cmd, o.fingerprintOptions) + + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + ci := WhichCI() + addAttestationFlags(cmd, o.CommonAttestationOptions, o.payload.CommonAttestationPayload, ci) + addAttestationGithubFlags(cmd, o.getRetriever().(*ghUtils.GithubConfig), ci) + cmd.Flags().BoolVar(&o.assert, "assert", false, assertPREvidenceFlag) + + err := RequireFlags(cmd, []string{"flow", "trail", "name", + "github-token", "github-org", "commit", "repository"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} diff --git a/cmd/kosli/attestPRGithub_test.go b/cmd/kosli/attestPRGithub_test.go new file mode 100644 index 000000000..472db359c --- /dev/null +++ b/cmd/kosli/attestPRGithub_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/kosli-dev/cli/internal/testHelpers" + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type AttestGithubPRCommandTestSuite struct { + flowName string + trailName string + artifactFingerprint string + suite.Suite + defaultKosliArguments string +} + +func (suite *AttestGithubPRCommandTestSuite) SetupTest() { + testHelpers.SkipIfEnvVarUnset(suite.T(), []string{"KOSLI_GITHUB_TOKEN"}) + + suite.flowName = "attest-github-pr" + suite.trailName = "test-123" + suite.artifactFingerprint = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --flow %s --trail %s --repo-root ../.. --commit a72d2b5cfae42cb95700b3645de0c8ba3129a2ae --host %s --org %s --api-token %s", suite.flowName, suite.trailName, global.Host, global.Org, global.ApiToken) + CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) + BeginTrail(suite.trailName, suite.flowName, "", suite.T()) + CreateArtifactOnTrail(suite.flowName, suite.trailName, "cli", suite.artifactFingerprint, "file1", suite.T()) +} + +func (suite *AttestGithubPRCommandTestSuite) TestAttestGithubPRCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when more arguments are provided", + cmd: fmt.Sprintf("attest pullrequest github foo bar %s", suite.defaultKosliArguments), + golden: "Error: accepts at most 1 arg(s), received 2\n", + }, + { + wantError: true, + name: "fails when missing a required flags", + cmd: fmt.Sprintf("attest pullrequest github foo %s", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"github-org\", \"name\", \"repository\" not set\n", + }, + { + wantError: true, + name: "fails when both --fingerprint and --artifact-type", + cmd: fmt.Sprintf("attest pullrequest github testdata/file1 --fingerprint xxxx --artifact-type file --name bar %s", suite.defaultKosliArguments), + golden: "Error: only one of --fingerprint, --artifact-type is allowed\n", + }, + { + wantError: true, + name: "fails when --fingerprint is not valid", + cmd: fmt.Sprintf("attest pullrequest github --name foo --fingerprint xxxx %s", suite.defaultKosliArguments), + golden: "Error: xxxx is not a valid SHA256 fingerprint. It should match the pattern ^([a-f0-9]{64})$\nUsage: kosli attest pullrequest github [IMAGE-NAME | FILE-PATH | DIR-PATH] [flags]\n", + }, + { + wantError: true, + name: "attesting against an artifact that does not exist fails", + cmd: fmt.Sprintf(`attest pullrequest github --fingerprint 1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo + --github-org kosli-dev --repository cli %s`, suite.defaultKosliArguments), + golden: "Error: Artifact with fingerprint '1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9' does not exist in flow 'attest-github-pr' belonging to organization 'docs-cmd-test-user'\n", + }, + { + name: "can attest github pr against an artifact using artifact name and --artifact-type", + cmd: fmt.Sprintf(`attest pullrequest github testdata/file1 --artifact-type file --name foo + --github-org kosli-dev --repository cli %s`, suite.defaultKosliArguments), + golden: "github pull request attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest github pr against an artifact using artifact name and --artifact-type when --name does not exist in the trail template", + cmd: fmt.Sprintf(`attest pullrequest github testdata/file1 --artifact-type file --name bar + --github-org kosli-dev --repository cli %s`, suite.defaultKosliArguments), + golden: "github pull request attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest github pr against an artifact using --fingerprint", + cmd: fmt.Sprintf(`attest pullrequest github --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo + --github-org kosli-dev --repository cli %s`, suite.defaultKosliArguments), + golden: "github pull request attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest github pr against a trail", + cmd: fmt.Sprintf(`attest pullrequest github --name bar + --github-org kosli-dev --repository cli %s`, suite.defaultKosliArguments), + golden: "github pull request attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest github pr against a trail when name is not found in the trail template", + cmd: fmt.Sprintf(`attest pullrequest github --name additional + --github-org kosli-dev --repository cli %s`, suite.defaultKosliArguments), + golden: "github pull request attestation 'additional' is reported to trail: test-123\n", + }, + { + name: "can attest github pr against an artifact it is created using dot syntax in --name", + cmd: fmt.Sprintf(`attest pullrequest github --name cli.foo + --github-org kosli-dev --repository cli %s`, suite.defaultKosliArguments), + golden: "github pull request attestation 'foo' is reported to trail: test-123\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestAttestGithubPRCommandTestSuite(t *testing.T) { + suite.Run(t, new(AttestGithubPRCommandTestSuite)) +} diff --git a/cmd/kosli/attestPRGitlab.go b/cmd/kosli/attestPRGitlab.go new file mode 100644 index 000000000..d25e854af --- /dev/null +++ b/cmd/kosli/attestPRGitlab.go @@ -0,0 +1,148 @@ +package main + +import ( + "io" + + gitlabUtils "github.com/kosli-dev/cli/internal/gitlab" + "github.com/spf13/cobra" +) + +const attestPRGitlabShortDesc = `Report a Gitlab merge request attestation to an artifact or a trail in a Kosli flow. ` + +const attestPRGitlabLongDesc = attestPRGitlabShortDesc + ` +It checks if a merge request exists for the artifact (based on its git commit) and reports the merge request evidence to the artifact in Kosli. +` + fingerprintDesc + +const attestPRGitlabExample = ` +# report a Gitlab merge request attestation about a pre-built docker artifact (kosli calculates the fingerprint): +kosli attest pullrequest gitlab yourDockerImageName \ + --artifact-type docker \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --gitlab-token yourGitlabToken \ + --gitlab-org yourGitlabOrg \ + --commit yourArtifactGitCommit \ + --repository yourGithubGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a Gitlab merge request attestation about a pre-built docker artifact (you provide the fingerprint): +kosli attest pullrequest gitlab \ + --fingerprint yourDockerImageFingerprint \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --gitlab-token yourGitlabToken \ + --gitlab-org yourGitlabOrg \ + --commit yourArtifactGitCommit \ + --repository yourGithubGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a Gitlab merge request attestation about a trail: +kosli attest pullrequest gitlab \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --gitlab-token yourGitlabToken \ + --gitlab-org yourGitlabOrg \ + --commit yourArtifactGitCommit \ + --repository yourGithubGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a Gitlab merge request attestation about an artifact which has not been reported yet in a trail: +kosli attest pullrequest gitlab \ + --name yourTemplateArtifactName.yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --gitlab-token yourGitlabToken \ + --gitlab-org yourGitlabOrg \ + --commit yourArtifactGitCommit \ + --repository yourGithubGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a Gitlab merge request attestation about a trail with an evidence file: +kosli attest pullrequest gitlab \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --gitlab-token yourGitlabToken \ + --gitlab-org yourGitlabOrg \ + --commit yourArtifactGitCommit \ + --repository yourGithubGitRepository \ + --evidence-paths=yourEvidencePathName \ + --api-token yourAPIToken \ + --org yourOrgName + +# fail if a merge request does not exist for your artifact +kosli attest pullrequest gitlab \ + --name yourTemplateArtifactName.yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --gitlab-token yourGitlabToken \ + --gitlab-org yourGitlabOrg \ + --commit yourArtifactGitCommit \ + --repository yourGithubGitRepository \ + --api-token yourAPIToken \ + --org yourOrgName \ + --assert +` + +func newAttestGitlabPRCmd(out io.Writer) *cobra.Command { + o := &attestPROptions{ + CommonAttestationOptions: &CommonAttestationOptions{ + fingerprintOptions: &fingerprintOptions{}, + }, + payload: PRAttestationPayload{ + CommonAttestationPayload: &CommonAttestationPayload{}, + }, + retriever: new(gitlabUtils.GitlabConfig), + } + cmd := &cobra.Command{ + Use: "gitlab [IMAGE-NAME | FILE-PATH | DIR-PATH]", + Aliases: []string{"gl"}, + Short: attestPRGitlabShortDesc, + Long: attestPRGitlabLongDesc, + Example: attestPRGitlabExample, + Args: cobra.MaximumNArgs(1), + Hidden: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + err = MuXRequiredFlags(cmd, []string{"fingerprint", "artifact-type"}, false) + if err != nil { + return err + } + + err = ValidateAttestationArtifactArg(args, o.fingerprintOptions.artifactType, o.payload.ArtifactFingerprint) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + return ValidateRegistryFlags(cmd, o.fingerprintOptions) + + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + ci := WhichCI() + addAttestationFlags(cmd, o.CommonAttestationOptions, o.payload.CommonAttestationPayload, ci) + addGitlabFlags(cmd, o.getRetriever().(*gitlabUtils.GitlabConfig), ci) + cmd.Flags().BoolVar(&o.assert, "assert", false, assertPREvidenceFlag) + + err := RequireFlags(cmd, []string{"flow", "trail", "name", + "gitlab-token", "gitlab-org", "commit", "repository"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} diff --git a/cmd/kosli/attestPRGitlab_test.go b/cmd/kosli/attestPRGitlab_test.go new file mode 100644 index 000000000..8e7d549ea --- /dev/null +++ b/cmd/kosli/attestPRGitlab_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "fmt" + "os" + "testing" + + "github.com/kosli-dev/cli/internal/testHelpers" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type AttestGitlabPRCommandTestSuite struct { + flowName string + trailName string + tmpDir string + artifactFingerprint string + suite.Suite + defaultKosliArguments string +} + +func (suite *AttestGitlabPRCommandTestSuite) SetupTest() { + testHelpers.SkipIfEnvVarUnset(suite.T(), []string{"KOSLI_GITLAB_TOKEN"}) + + suite.flowName = "attest-gitlab-pr" + suite.trailName = "test-123" + suite.artifactFingerprint = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + + var err error + suite.tmpDir, err = os.MkdirTemp("", "testDir") + require.NoError(suite.T(), err) + _, err = testHelpers.CloneGitRepo("https://gitlab.com/ewelinawilkosz/merkely-gitlab-demo.git", suite.tmpDir) + require.NoError(suite.T(), err) + + suite.defaultKosliArguments = fmt.Sprintf(" --flow %s --trail %s --repo-root %s --commit e6510880aecdc05d79104d937e1adb572bd91911 --host %s --org %s --api-token %s", suite.flowName, suite.trailName, suite.tmpDir, global.Host, global.Org, global.ApiToken) + CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) + BeginTrail(suite.trailName, suite.flowName, "", suite.T()) + CreateArtifactOnTrail(suite.flowName, suite.trailName, "cli", suite.artifactFingerprint, "file1", suite.T()) +} + +func (suite *AttestGitlabPRCommandTestSuite) TearDownSuite() { + os.RemoveAll(suite.tmpDir) +} + +func (suite *AttestGitlabPRCommandTestSuite) TestAttestGitlabPRCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when more arguments are provided", + cmd: fmt.Sprintf("attest pullrequest gitlab foo bar %s", suite.defaultKosliArguments), + golden: "Error: accepts at most 1 arg(s), received 2\n", + }, + { + wantError: true, + name: "fails when missing a required flags", + cmd: fmt.Sprintf("attest pullrequest gitlab foo %s", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"gitlab-org\", \"name\", \"repository\" not set\n", + }, + { + wantError: true, + name: "fails when both --fingerprint and --artifact-type", + cmd: fmt.Sprintf("attest pullrequest gitlab testdata/file1 --fingerprint xxxx --artifact-type file --name bar %s", suite.defaultKosliArguments), + golden: "Error: only one of --fingerprint, --artifact-type is allowed\n", + }, + { + wantError: true, + name: "fails when --fingerprint is not valid", + cmd: fmt.Sprintf("attest pullrequest gitlab --name foo --fingerprint xxxx %s", suite.defaultKosliArguments), + golden: "Error: xxxx is not a valid SHA256 fingerprint. It should match the pattern ^([a-f0-9]{64})$\nUsage: kosli attest pullrequest gitlab [IMAGE-NAME | FILE-PATH | DIR-PATH] [flags]\n", + }, + { + wantError: true, + name: "attesting against an artifact that does not exist fails", + cmd: fmt.Sprintf(`attest pullrequest gitlab --fingerprint 1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo + --gitlab-org ewelinawilkosz --repository merkely-gitlab-demo %s`, suite.defaultKosliArguments), + golden: "Error: Artifact with fingerprint '1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9' does not exist in flow 'attest-gitlab-pr' belonging to organization 'docs-cmd-test-user'\n", + }, + { + name: "can attest gitlab pr against an artifact using artifact name and --artifact-type", + cmd: fmt.Sprintf(`attest pullrequest gitlab testdata/file1 --artifact-type file --name foo + --gitlab-org ewelinawilkosz --repository merkely-gitlab-demo %s`, suite.defaultKosliArguments), + golden: "gitlab merge request attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest gitlab pr against an artifact using artifact name and --artifact-type when --name does not exist in the trail template", + cmd: fmt.Sprintf(`attest pullrequest gitlab testdata/file1 --artifact-type file --name bar + --gitlab-org ewelinawilkosz --repository merkely-gitlab-demo %s`, suite.defaultKosliArguments), + golden: "gitlab merge request attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest gitlab pr against an artifact using --fingerprint", + cmd: fmt.Sprintf(`attest pullrequest gitlab --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo + --gitlab-org ewelinawilkosz --repository merkely-gitlab-demo %s`, suite.defaultKosliArguments), + golden: "gitlab merge request attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest gitlab pr against a trail", + cmd: fmt.Sprintf(`attest pullrequest gitlab --name bar + --gitlab-org ewelinawilkosz --repository merkely-gitlab-demo %s`, suite.defaultKosliArguments), + golden: "gitlab merge request attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest gitlab pr against a trail when name is not found in the trail template", + cmd: fmt.Sprintf(`attest pullrequest gitlab --name additional + --gitlab-org ewelinawilkosz --repository merkely-gitlab-demo %s`, suite.defaultKosliArguments), + golden: "gitlab merge request attestation 'additional' is reported to trail: test-123\n", + }, + { + name: "can attest gitlab pr against an artifact it is created using dot syntax in --name", + cmd: fmt.Sprintf(`attest pullrequest gitlab --name cli.foo + --gitlab-org ewelinawilkosz --repository merkely-gitlab-demo %s`, suite.defaultKosliArguments), + golden: "gitlab merge request attestation 'foo' is reported to trail: test-123\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestAttestGitlabPRCommandTestSuite(t *testing.T) { + suite.Run(t, new(AttestGitlabPRCommandTestSuite)) +} diff --git a/cmd/kosli/attestSnyk.go b/cmd/kosli/attestSnyk.go new file mode 100644 index 000000000..9baf3e1c4 --- /dev/null +++ b/cmd/kosli/attestSnyk.go @@ -0,0 +1,165 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "os" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +type SnykAttestationPayload struct { + *CommonAttestationPayload + SnykResults interface{} `json:"snyk_results"` +} + +type attestSnykOptions struct { + *CommonAttestationOptions + snykJsonFilePath string + payload SnykAttestationPayload +} + +const attestSnykShortDesc = `Report a snyk attestation to an artifact or a trail in a Kosli flow. ` + +const attestSnykLongDesc = attestSnykShortDesc + ` +` + fingerprintDesc + +const attestSnykExample = ` +# report a snyk attestation about a pre-built docker artifact (kosli calculates the fingerprint): +kosli attest snyk yourDockerImageName \ + --artifact-type docker \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --scan-results yourSnykJSONScanResults \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a snyk attestation about a pre-built docker artifact (you provide the fingerprint): +kosli attest snyk \ + --fingerprint yourDockerImageFingerprint \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --scan-results yourSnykJSONScanResults \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a snyk attestation about a trail: +kosli attest snyk \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --scan-results yourSnykJSONScanResults \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a snyk attestation about an artifact which has not been reported yet in a trail: +kosli attest snyk \ + --name yourTemplateArtifactName.yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --scan-results yourSnykJSONScanResults \ + --api-token yourAPIToken \ + --org yourOrgName + +# report a snyk attestation about a trail with an evidence file: +kosli attest snyk \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --scan-results yourSnykJSONScanResults \ + --evidence-paths=yourEvidencePathName \ + --api-token yourAPIToken \ + --org yourOrgName +` + +func newAttestSnykCmd(out io.Writer) *cobra.Command { + o := &attestSnykOptions{ + CommonAttestationOptions: &CommonAttestationOptions{ + fingerprintOptions: &fingerprintOptions{}, + }, + payload: SnykAttestationPayload{ + CommonAttestationPayload: &CommonAttestationPayload{}, + }, + } + cmd := &cobra.Command{ + Use: "snyk [IMAGE-NAME | FILE-PATH | DIR-PATH]", + Short: attestSnykShortDesc, + Long: attestSnykLongDesc, + Example: attestSnykExample, + Args: cobra.MaximumNArgs(1), + Hidden: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + err = MuXRequiredFlags(cmd, []string{"fingerprint", "artifact-type"}, false) + if err != nil { + return err + } + + err = ValidateAttestationArtifactArg(args, o.fingerprintOptions.artifactType, o.payload.ArtifactFingerprint) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + return ValidateRegistryFlags(cmd, o.fingerprintOptions) + + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + ci := WhichCI() + addAttestationFlags(cmd, o.CommonAttestationOptions, o.payload.CommonAttestationPayload, ci) + cmd.Flags().StringVarP(&o.snykJsonFilePath, "scan-results", "R", "", snykJsonResultsFileFlag) + + err := RequireFlags(cmd, []string{"flow", "trail", "name", "scan-results"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *attestSnykOptions) run(args []string) error { + url := fmt.Sprintf("%s/api/v2/attestations/%s/%s/trail/%s/snyk", global.Host, global.Org, o.flowName, o.trailName) + + err := o.CommonAttestationOptions.run(args, o.payload.CommonAttestationPayload) + if err != nil { + return err + } + + o.payload.SnykResults, err = LoadJsonData(o.snykJsonFilePath) + if err != nil { + return err + } + + form, cleanupNeeded, evidencePath, err := prepareAttestationForm(o.payload, o.evidencePaths) + if err != nil { + return err + } + // if we created a tar package, remove it after uploading it + if cleanupNeeded { + defer os.Remove(evidencePath) + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + Form: form, + DryRun: global.DryRun, + Password: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("snyk attestation '%s' is reported to trail: %s", o.payload.AttestationName, o.trailName) + } + return err +} diff --git a/cmd/kosli/attestSnyk_test.go b/cmd/kosli/attestSnyk_test.go new file mode 100644 index 000000000..6949c7d1a --- /dev/null +++ b/cmd/kosli/attestSnyk_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type AttestSnykCommandTestSuite struct { + flowName string + trailName string + artifactFingerprint string + suite.Suite + defaultKosliArguments string +} + +func (suite *AttestSnykCommandTestSuite) SetupTest() { + suite.flowName = "attest-snyk" + suite.trailName = "test-123" + suite.artifactFingerprint = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --flow %s --trail %s --repo-root ../.. --host %s --org %s --api-token %s", suite.flowName, suite.trailName, global.Host, global.Org, global.ApiToken) + CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) + BeginTrail(suite.trailName, suite.flowName, "", suite.T()) + CreateArtifactOnTrail(suite.flowName, suite.trailName, "cli", suite.artifactFingerprint, "file1", suite.T()) +} + +func (suite *AttestSnykCommandTestSuite) TestAttestSnykCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when more arguments are provided", + cmd: fmt.Sprintf("attest snyk foo bar %s", suite.defaultKosliArguments), + golden: "Error: accepts at most 1 arg(s), received 2\n", + }, + { + wantError: true, + name: "fails when missing a required flags", + cmd: fmt.Sprintf("attest snyk foo %s", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"name\", \"scan-results\" not set\n", + }, + { + wantError: true, + name: "fails when both --fingerprint and --artifact-type", + cmd: fmt.Sprintf("attest snyk testdata/file1 --fingerprint xxxx --artifact-type file --name bar --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "Error: only one of --fingerprint, --artifact-type is allowed\n", + }, + { + wantError: true, + name: "fails when --fingerprint is not valid", + cmd: fmt.Sprintf("attest snyk --name foo --fingerprint xxxx --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "Error: xxxx is not a valid SHA256 fingerprint. It should match the pattern ^([a-f0-9]{64})$\nUsage: kosli attest snyk [IMAGE-NAME | FILE-PATH | DIR-PATH] [flags]\n", + }, + { + wantError: true, + name: "attesting against an artifact that does not exist fails", + cmd: fmt.Sprintf("attest snyk --fingerprint 1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo --commit HEAD --url example.com --scan-results testdata/snyk_scan_example.json %s", suite.defaultKosliArguments), + golden: "Error: Artifact with fingerprint '1234e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9' does not exist in flow 'attest-snyk' belonging to organization 'docs-cmd-test-user'\n", + }, + { + wantError: true, + name: "fails when --snyk-results is missing", + cmd: fmt.Sprintf("attest snyk testdata/file1 --artifact-type file --name foo --commit HEAD --url example.com %s", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"scan-results\" not set\n", + }, + { + name: "can attest snyk against an artifact using artifact name and --artifact-type", + cmd: fmt.Sprintf("attest snyk testdata/file1 --artifact-type file --name foo --commit HEAD --url example.com --scan-results testdata/snyk_scan_example.json %s", suite.defaultKosliArguments), + golden: "snyk attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest snyk against an artifact using artifact name and --artifact-type when --name does not exist in the trail template", + cmd: fmt.Sprintf("attest snyk testdata/file1 --artifact-type file --name bar --commit HEAD --url example.com --scan-results testdata/snyk_scan_example.json %s", suite.defaultKosliArguments), + golden: "snyk attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest snyk against an artifact using --fingerprint", + cmd: fmt.Sprintf("attest snyk --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo --commit HEAD --url example.com --scan-results testdata/snyk_scan_example.json %s", suite.defaultKosliArguments), + golden: "snyk attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can attest snyk against a trail", + cmd: fmt.Sprintf("attest snyk --name bar --commit HEAD --url example.com --scan-results testdata/snyk_scan_example.json %s", suite.defaultKosliArguments), + golden: "snyk attestation 'bar' is reported to trail: test-123\n", + }, + { + name: "can attest snyk against a trail when name is not found in the trail template", + cmd: fmt.Sprintf("attest snyk --name additional --commit HEAD --url example.com --scan-results testdata/snyk_scan_example.json %s", suite.defaultKosliArguments), + golden: "snyk attestation 'additional' is reported to trail: test-123\n", + }, + { + name: "can attest snyk against an artifact it is created using dot syntax in --name", + cmd: fmt.Sprintf("attest snyk --name cli.foo --commit HEAD --url example.com --scan-results testdata/snyk_scan_example.json %s", suite.defaultKosliArguments), + golden: "snyk attestation 'foo' is reported to trail: test-123\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestAttestSnykCommandTestSuite(t *testing.T) { + suite.Run(t, new(AttestSnykCommandTestSuite)) +} diff --git a/cmd/kosli/attestation.go b/cmd/kosli/attestation.go new file mode 100644 index 000000000..d4e31fcb7 --- /dev/null +++ b/cmd/kosli/attestation.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/kosli-dev/cli/internal/gitview" + "github.com/kosli-dev/cli/internal/requests" +) + +type CommonAttestationPayload struct { + ArtifactFingerprint string `json:"artifact_fingerprint,omitempty"` + Commit *gitview.BasicCommitInfo `json:"git_commit_info,omitempty"` + AttestationName string `json:"step_name"` + TargetArtifacts []string `json:"target_artifacts,omitempty"` + EvidenceURL string `json:"evidence_url,omitempty"` + EvidenceFingerprint string `json:"evidence_fingerprint,omitempty"` + Url string `json:"url,omitempty"` + UserData interface{} `json:"user_data,omitempty"` +} + +type CommonAttestationOptions struct { + fingerprintOptions *fingerprintOptions + attestationNameTemplate string + flowName string + trailName string + userDataFilePath string + evidencePaths []string + commitSHA string + srcRepoRoot string +} + +func (o *CommonAttestationOptions) run(args []string, payload *CommonAttestationPayload) error { + var err error + + p1, p2, err := parseAttestationNameTemplate(o.attestationNameTemplate) + if err != nil { + return err + } + if p1 != "" && p2 != "" { + payload.TargetArtifacts = []string{p1} + payload.AttestationName = p2 + } else { + payload.AttestationName = p1 + } + + if o.fingerprintOptions.artifactType != "" { + payload.ArtifactFingerprint, err = GetSha256Digest(args[0], o.fingerprintOptions, logger) + if err != nil { + return err + } + } + + if o.commitSHA != "" { + gv, err := gitview.New(o.srcRepoRoot) + if err != nil { + return err + } + commitInfo, err := gv.GetCommitInfoFromCommitSHA(o.commitSHA) + if err != nil { + return err + } + payload.Commit = &commitInfo.BasicCommitInfo + } + + payload.UserData, err = LoadJsonData(o.userDataFilePath) + return err +} + +func prepareAttestationForm(payload interface{}, evidencePaths []string) ([]requests.FormItem, bool, string, error) { + form, cleanupNeeded, evidencePath, err := newAttestationForm(payload, evidencePaths) + if err != nil { + return []requests.FormItem{}, cleanupNeeded, evidencePath, err + } + return form, cleanupNeeded, evidencePath, nil +} + +func parseAttestationNameTemplate(template string) (string, string, error) { + parts := strings.Split(template, ".") + if len(parts) == 1 { + return parts[0], "", nil + } else if len(parts) == 2 { + return parts[0], parts[1], nil + } else { + return "", "", fmt.Errorf("invalid attestation name format") + } +} diff --git a/cmd/kosli/begin.go b/cmd/kosli/begin.go new file mode 100644 index 000000000..535d9cd84 --- /dev/null +++ b/cmd/kosli/begin.go @@ -0,0 +1,23 @@ +package main + +import ( + "io" + + "github.com/spf13/cobra" +) + +const beginDesc = `All Kosli begin commands.` + +func newBeginCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "begin", + Short: beginDesc, + Long: beginDesc, + } + + // Add subcommands + cmd.AddCommand( + newBeginTrailCmd(out), + ) + return cmd +} diff --git a/cmd/kosli/beginTrail.go b/cmd/kosli/beginTrail.go new file mode 100644 index 000000000..1afffb1ee --- /dev/null +++ b/cmd/kosli/beginTrail.go @@ -0,0 +1,109 @@ +package main + +import ( + "fmt" + "io" + "net/http" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const beginTrailShortDesc = `Begin or update a Kosli flow trail.` + +const beginTrailExample = ` +# begin/update a Kosli flow trail: +kosli begin trail yourTrailName \ + --description yourTrailDescription \ + --template-file /path/to/your/template/file.yml \ + --user-data /path/to/your/user-data/file.json \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type beginTrailOptions struct { + payload TrailPayload + templateFile string + userDataFile string + flow string +} + +type TrailPayload struct { + Name string `json:"name"` + Description string `json:"description"` + UserData interface{} `json:"user_data"` +} + +func newBeginTrailCmd(out io.Writer) *cobra.Command { + o := new(beginTrailOptions) + cmd := &cobra.Command{ + Use: "trail TRAIL-NAME", + Hidden: true, + Short: beginTrailShortDesc, + Long: beginTrailShortDesc, + Example: beginTrailExample, + Args: cobra.MaximumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + if len(args) == 0 { + return fmt.Errorf("trail name must be provided as an argument") + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + cmd.Flags().StringVar(&o.flow, "flow", "", flowNameFlag) + cmd.Flags().StringVar(&o.payload.Description, "description", "", trailDescriptionFlag) + cmd.Flags().StringVarP(&o.templateFile, "template-file", "f", "", templateFileFlag) + cmd.Flags().StringVarP(&o.userDataFile, "user-data", "u", "", trailUserDataFlag) + addDryRunFlag(cmd) + + err := RequireFlags(cmd, []string{"flow"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *beginTrailOptions) run(args []string) error { + url := fmt.Sprintf("%s/api/v2/trails/%s/%s", global.Host, global.Org, o.flow) + + o.payload.Name = args[0] + + var err error + o.payload.UserData, err = LoadJsonData(o.userDataFile) + if err != nil { + return err + } + + form, err := newFlowForm(o.payload, o.templateFile, false) + if err != nil { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPut, + URL: url, + Form: form, + DryRun: global.DryRun, + Password: global.ApiToken, + } + + res, err := kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + verb := "begun" + if res.Resp.StatusCode == 200 { + verb = "updated" + } + logger.Info("trail '%s' was %s", o.payload.Name, verb) + } + return err +} diff --git a/cmd/kosli/beginTrail_test.go b/cmd/kosli/beginTrail_test.go new file mode 100644 index 000000000..7ecd1e62b --- /dev/null +++ b/cmd/kosli/beginTrail_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type BeginTrailCommandTestSuite struct { + flowName string + suite.Suite + defaultKosliArguments string +} + +func (suite *BeginTrailCommandTestSuite) SetupTest() { + suite.flowName = "begin-trail" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) +} + +func (suite *BeginTrailCommandTestSuite) TestBeginTrailCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when more arguments are provided", + cmd: fmt.Sprintf("begin trail trail1 xxx --flow %s %s", suite.flowName, suite.defaultKosliArguments), + golden: "Error: accepts at most 1 arg(s), received 2\n", + }, + { + wantError: true, + name: "fails when name is considered invalid by the server", + cmd: fmt.Sprintf("begin trail foo?$bar --flow %s %s", suite.flowName, suite.defaultKosliArguments), + golden: "Error: Input payload validation failed: map[name:'foo?$bar' does not match '^[a-zA-Z][a-zA-Z0-9\\\\-_\\\\.~]*$']\n", + }, + { + wantError: true, + name: "fails when --flow is missing", + cmd: "begin trail test-123 --description \"my new flow\" " + suite.defaultKosliArguments, + golden: "Error: required flag(s) \"flow\" not set\n", + }, + { + wantError: true, + name: "beginning a trail with an invalid template fails", + cmd: fmt.Sprintf("begin trail test-123 --flow %s --template-file testdata/invalid_template.yml %s", suite.flowName, suite.defaultKosliArguments), + goldenRegex: "Error: template file is invalid 1 validation error for Template\n.*", + }, + { + name: "can begin a trail with a valid template", + cmd: fmt.Sprintf("begin trail test-123 --flow %s --template-file testdata/valid_template.yml %s", suite.flowName, suite.defaultKosliArguments), + golden: "trail 'test-123' was begun\n", + }, + { + name: "can update a trail with a description", + cmd: fmt.Sprintf("begin trail test-123 --flow %s --template-file testdata/valid_template.yml --description \"my new flow\" %s", suite.flowName, suite.defaultKosliArguments), + golden: "trail 'test-123' was updated\n", + }, + { + wantError: true, + name: "missing --org flag causes an error", + cmd: "begin trail test-123 --flow my-modern-flow -H http://localhost:8001 -a eyJhbGciOiJIUzUxMiIsImlhdCI6MTYyNTY0NDUwMCwiZXhwIjoxNjI1NjQ4MTAwfQ.eyJpZCI6IjgzYTBkY2Q1In0.1B-xDlajF46vipL49zPbnXBRgotqGGcB3lxwpJxZ3HNce07E0p2LwO7UDYve9j2G9fQtKrKhUKvVR97SQOEFLQ", + golden: "Error: --org is not set\nUsage: kosli begin trail TRAIL-NAME [flags]\n", + }, + { + wantError: true, + name: "missing --api-token flag causes an error", + cmd: "begin trail test-123 --flow my-modern-flow --org cyber-dojo -H http://localhost:8001", + golden: "Error: --api-token is not set\nUsage: kosli begin trail TRAIL-NAME [flags]\n", + }, + { + wantError: true, + name: "missing name argument fails", + cmd: "begin trail --flow my-modern-flow -H http://localhost:8001 --org cyber-dojo -a eyJhbGciOiJIUzUxMiIsImlhdCI6MTYyNTY0NDUwMCwiZXhwIjoxNjI1NjQ4MTAwfQ.eyJpZCI6IjgzYTBkY2Q1In0.1B-xDlajF46vipL49zPbnXBRgotqGGcB3lxwpJxZ3HNce07E0p2LwO7UDYve9j2G9fQtKrKhUKvVR97SQOEFLQ", + golden: "Error: trail name must be provided as an argument\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestBeginTrailCommandTestSuite(t *testing.T) { + suite.Run(t, new(BeginTrailCommandTestSuite)) +} diff --git a/cmd/kosli/cli_utils.go b/cmd/kosli/cli_utils.go index 6f5150ba5..00cdc0a8b 100644 --- a/cmd/kosli/cli_utils.go +++ b/cmd/kosli/cli_utils.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "net/http" - "net/url" urlPackage "net/url" "os" "path/filepath" @@ -460,6 +459,20 @@ func ValidateArtifactArg(args []string, artifactType, inputSha256 string, always return nil } +// ValidateAttestationArtifactArg validates the artifact name or path argument and fingerprint flag +func ValidateAttestationArtifactArg(args []string, artifactType, inputSha256 string) error { + if artifactType != "" && (len(args) == 0 || args[0] == "") { + return fmt.Errorf("artifact name argument is required when --artifact-type is set") + } + + if inputSha256 != "" { + if err := digest.ValidateDigest(inputSha256); err != nil { + return err + } + } + return nil +} + // ValidateRegistryFlags validates that you provide all registry information necessary for // remote digest. func ValidateRegistryFlags(cmd *cobra.Command, o *fingerprintOptions) error { @@ -661,7 +674,7 @@ func handleSnapshotExpressions(expression string) (string, string, error) { if items[0] == "" { return "", "", fmt.Errorf("invalid expression: %s. Environment name is missing", expression) } - return items[0], url.PathEscape(separator + items[1]), nil + return items[0], urlPackage.PathEscape(separator + items[1]), nil } // handleArtifactExpression parses artifact expressions (with @ and :) and returns diff --git a/cmd/kosli/create.go b/cmd/kosli/create.go index 1d42abf79..ff47e6d26 100644 --- a/cmd/kosli/create.go +++ b/cmd/kosli/create.go @@ -20,6 +20,7 @@ func newCreateCmd(out io.Writer) *cobra.Command { newCreateEnvironmentCmd(out), newCreateFlowCmd(out), newCreateAuditTrailCmd(out), + newCreateFlowWithTemplateCmd(out), ) return cmd } diff --git a/cmd/kosli/createFlowWithTemplate.go b/cmd/kosli/createFlowWithTemplate.go new file mode 100644 index 000000000..2cf205cd2 --- /dev/null +++ b/cmd/kosli/createFlowWithTemplate.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + "io" + "net/http" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const createFlowWithTemplateShortDesc = `Create or update a Kosli flow.` + +const createFlowWithTemplateLongDesc = createFlowShortDesc + ` +You can specify flow parameters in flags.` + +const createFlowWithTemplateExample = ` +# create/update a Kosli flow: +kosli create flow yourFlowName \ + --description yourFlowDescription \ + --visibility private OR public \ + --template-file /path/to/your/template/file.yml \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type createFlowWithTemplateOptions struct { + payload FlowWithTemplatePayload + TemplateFile string +} + +type FlowWithTemplatePayload struct { + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` +} + +func newCreateFlowWithTemplateCmd(out io.Writer) *cobra.Command { + o := new(createFlowWithTemplateOptions) + cmd := &cobra.Command{ + Use: "flow2 FLOW-NAME", + Hidden: true, + Short: createFlowWithTemplateShortDesc, + Long: createFlowWithTemplateLongDesc, + Example: createFlowWithTemplateExample, + Args: cobra.MaximumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + if len(args) == 0 { + return fmt.Errorf("flow name must be provided as an argument") + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + cmd.Flags().StringVar(&o.payload.Description, "description", "", flowDescriptionFlag) + cmd.Flags().StringVar(&o.payload.Visibility, "visibility", "private", visibilityFlag) + cmd.Flags().StringVarP(&o.TemplateFile, "template-file", "f", "", templateFileFlag) + addDryRunFlag(cmd) + + err := RequireFlags(cmd, []string{"template-file"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *createFlowWithTemplateOptions) run(args []string) error { + url := fmt.Sprintf("%s/api/v2/flows/%s/template_file", global.Host, global.Org) + + o.payload.Name = args[0] + form, err := newFlowForm(o.payload, o.TemplateFile, false) + if err != nil { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPut, + URL: url, + Form: form, + DryRun: global.DryRun, + Password: global.ApiToken, + } + res, err := kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + verb := "created" + if res.Resp.StatusCode == 200 { + verb = "updated" + } + logger.Info("flow '%s' was %s", o.payload.Name, verb) + } + return err +} + +// newFlowForm constructs a list of FormItems for a flow with a template file +// form submission. +func newFlowForm(payload interface{}, templateFile string, templateRequired bool) ([]requests.FormItem, error) { + if templateFile == "" && templateRequired { + return []requests.FormItem{}, fmt.Errorf("cannot create a flow form without a template file") + } + form := []requests.FormItem{ + {Type: "field", FieldName: "data_json", Content: payload}, + } + + if templateFile != "" { + form = append(form, requests.FormItem{Type: "file", FieldName: "template_file", Content: templateFile}) + logger.Debug("template file %s will be uploaded", templateFile) + } + + return form, nil +} diff --git a/cmd/kosli/createFlowWithTemplate_test.go b/cmd/kosli/createFlowWithTemplate_test.go new file mode 100644 index 000000000..7759db1f1 --- /dev/null +++ b/cmd/kosli/createFlowWithTemplate_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type CreateFlowWithTemplateCommandTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *CreateFlowWithTemplateCommandTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) +} + +func (suite *CreateFlowWithTemplateCommandTestSuite) TestCreateFlowCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when more arguments are provided", + cmd: "create flow2 newFlowWithTemplate xxx" + suite.defaultKosliArguments, + golden: "Error: accepts at most 1 arg(s), received 2\n", + }, + { + wantError: true, + name: "fails when name is considered invalid by the server", + cmd: "create flow2 foo_bar --template-file testdata/valid_template.yml" + suite.defaultKosliArguments, + golden: "Error: Input payload validation failed: map[name:'foo_bar' does not match '^[a-zA-Z0-9\\\\-]+$']\n", + }, + { + wantError: true, + name: "fails when --template-file is missing", + cmd: "create flow2 newFlowWithTemplate --description \"my new flow\" " + suite.defaultKosliArguments, + golden: "Error: required flag(s) \"template-file\" not set\n", + }, + { + wantError: true, + name: "creating a flow with an invalid template fails", + cmd: "create flow2 newFlowWithTemplate --template-file testdata/invalid_template.yml --description \"my new flow\" " + suite.defaultKosliArguments, + goldenRegex: "Error: template file is invalid 1 validation error for Template\n.*", + }, + { + name: "can create a flow with a valid template", + cmd: "create flow2 newFlowWithTemplate --template-file testdata/valid_template.yml --description \"my new flow\" " + suite.defaultKosliArguments, + golden: "flow 'newFlowWithTemplate' was created\n", + }, + { + name: "re-creating a flow updates its metadata", + cmd: "create flow2 newFlowWithTemplate --template-file testdata/valid_template.yml --description \"changed description\" " + suite.defaultKosliArguments, + golden: "flow 'newFlowWithTemplate' was updated\n", + }, + { + wantError: true, + name: "missing --org flag causes an error", + cmd: "create flow2 newFlowWithTemplate --description \"my new flow\" -H http://localhost:8001 -a eyJhbGciOiJIUzUxMiIsImlhdCI6MTYyNTY0NDUwMCwiZXhwIjoxNjI1NjQ4MTAwfQ.eyJpZCI6IjgzYTBkY2Q1In0.1B-xDlajF46vipL49zPbnXBRgotqGGcB3lxwpJxZ3HNce07E0p2LwO7UDYve9j2G9fQtKrKhUKvVR97SQOEFLQ", + golden: "Error: --org is not set\nUsage: kosli create flow2 FLOW-NAME [flags]\n", + }, + { + wantError: true, + name: "missing --api-token flag causes an error", + cmd: "create flow2 newFlowWithTemplate --description \"my new flow\" --org cyber-dojo -H http://localhost:8001", + golden: "Error: --api-token is not set\nUsage: kosli create flow2 FLOW-NAME [flags]\n", + }, + { + wantError: true, + name: "missing name argument fails", + cmd: "create flow2 --description \"my new flow\" -H http://localhost:8001 --org cyber-dojo -a eyJhbGciOiJIUzUxMiIsImlhdCI6MTYyNTY0NDUwMCwiZXhwIjoxNjI1NjQ4MTAwfQ.eyJpZCI6IjgzYTBkY2Q1In0.1B-xDlajF46vipL49zPbnXBRgotqGGcB3lxwpJxZ3HNce07E0p2LwO7UDYve9j2G9fQtKrKhUKvVR97SQOEFLQ", + golden: "Error: flow name must be provided as an argument\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestCreateFlowWithTemplateCommandTestSuite(t *testing.T) { + suite.Run(t, new(CreateFlowWithTemplateCommandTestSuite)) +} diff --git a/cmd/kosli/evidence.go b/cmd/kosli/evidence.go index adfdd41bf..abb48fc14 100644 --- a/cmd/kosli/evidence.go +++ b/cmd/kosli/evidence.go @@ -59,3 +59,28 @@ func newEvidenceForm(payload interface{}, evidencePaths []string) ( return form, cleanupNeeded, evidencePath, nil } + +// newAttestationForm constructs a list of FormItems for an evidence +// form submission. +func newAttestationForm(payload interface{}, evidencePaths []string) ( + []requests.FormItem, bool, string, error, +) { + form := []requests.FormItem{ + {Type: "field", FieldName: "data_json", Content: payload}, + } + + var evidencePath string + var cleanupNeeded bool + var err error + + if len(evidencePaths) > 0 { + evidencePath, cleanupNeeded, err = getPathOfEvidenceFileToUpload(evidencePaths) + if err != nil { + return form, cleanupNeeded, evidencePath, err + } + form = append(form, requests.FormItem{Type: "file", FieldName: "evidence_file", Content: evidencePath}) + logger.Debug("evidence file %s will be uploaded", evidencePath) + } + + return form, cleanupNeeded, evidencePath, nil +} diff --git a/cmd/kosli/flags.go b/cmd/kosli/flags.go index ae7b9b617..694a68795 100644 --- a/cmd/kosli/flags.go +++ b/cmd/kosli/flags.go @@ -55,6 +55,20 @@ func addGitlabFlags(cmd *cobra.Command, gitlabConfig *gitlabUtils.GitlabConfig, cmd.Flags().StringVar(&gitlabConfig.Repository, "repository", DefaultValue(ci, "repository"), repositoryFlag) } +func addAttestationGithubFlags(cmd *cobra.Command, githubConfig *ghUtils.GithubConfig, ci string) { + cmd.Flags().StringVar(&githubConfig.Token, "github-token", "", githubTokenFlag) + cmd.Flags().StringVar(&githubConfig.Org, "github-org", DefaultValue(ci, "org"), githubOrgFlag) + cmd.Flags().StringVar(&githubConfig.Repository, "repository", DefaultValue(ci, "repository"), repositoryFlag) + cmd.Flags().StringVar(&githubConfig.BaseURL, "github-base-url", "", githubBaseURLFlag) +} + +func addAttestationAzureFlags(cmd *cobra.Command, azureConfig *azUtils.AzureConfig, ci string) { + cmd.Flags().StringVar(&azureConfig.Token, "azure-token", "", azureTokenFlag) + cmd.Flags().StringVar(&azureConfig.OrgURL, "azure-org-url", DefaultValue(ci, "org-url"), azureOrgUrlFlag) + cmd.Flags().StringVar(&azureConfig.Project, "project", DefaultValue(ci, "project"), azureProjectFlag) + cmd.Flags().StringVar(&azureConfig.Repository, "repository", DefaultValue(ci, "repository"), repositoryFlag) +} + func addArtifactPRFlags(cmd *cobra.Command, o *pullRequestArtifactOptions, ci string) { addArtifactEvidenceFlags(cmd, &o.payload.TypedEvidencePayload, ci) cmd.Flags().StringVarP(&o.userDataFilePath, "user-data", "u", "", evidenceUserDataFlag) @@ -92,3 +106,20 @@ func addListFlags(cmd *cobra.Command, o *listOptions) { cmd.Flags().IntVar(&o.pageNumber, "page", 1, pageNumberFlag) cmd.Flags().IntVarP(&o.pageLimit, "page-limit", "n", 15, pageLimitFlag) } + +func addAttestationFlags(cmd *cobra.Command, o *CommonAttestationOptions, payload *CommonAttestationPayload, ci string) { + cmd.Flags().StringVarP(&payload.ArtifactFingerprint, "fingerprint", "F", "", attestationFingerprintFlag) + cmd.Flags().StringVar(&o.commitSHA, "commit", DefaultValue(ci, "git-commit"), attestationCommitFlag) + cmd.Flags().StringVarP(&payload.Url, "url", "b", DefaultValue(ci, "build-url"), attestationUrlFlag) + cmd.Flags().StringVarP(&o.attestationNameTemplate, "name", "n", "", attestationNameFlag) + cmd.Flags().StringVar(&payload.EvidenceFingerprint, "evidence-fingerprint", "", evidenceFingerprintFlag) + cmd.Flags().StringVar(&payload.EvidenceURL, "evidence-url", "", evidenceURLFlag) + cmd.Flags().StringVarP(&o.flowName, "flow", "f", "", flowNameFlag) + cmd.Flags().StringVarP(&o.trailName, "trail", "T", "", trailNameFlag) + cmd.Flags().StringVarP(&o.userDataFilePath, "user-data", "u", "", attestationUserDataFlag) + cmd.Flags().StringSliceVarP(&o.evidencePaths, "evidence-paths", "e", []string{}, evidencePathsFlag) + cmd.Flags().StringVar(&o.srcRepoRoot, "repo-root", ".", attestationRepoRootFlag) + + addFingerprintFlags(cmd, o.fingerprintOptions) + addDryRunFlag(cmd) +} diff --git a/cmd/kosli/pullrequest.go b/cmd/kosli/pullrequest.go index ad56f8faf..4b9f8e121 100644 --- a/cmd/kosli/pullrequest.go +++ b/cmd/kosli/pullrequest.go @@ -90,22 +90,64 @@ func (o *pullRequestArtifactOptions) run(out io.Writer, args []string) error { return err } -func getGitProviderAndLabel(retriever interface{}) (string, string) { - label := "pull request" - provider := "" - t := reflect.TypeOf(retriever) - switch t { - case reflect.TypeOf(&gitlabUtils.GitlabConfig{}): - provider = "gitlab" - label = "merge request" - case reflect.TypeOf(&ghUtils.GithubConfig{}): - provider = "github" - case reflect.TypeOf(&azUtils.AzureConfig{}): - provider = "azure" - case reflect.TypeOf(&bbUtils.Config{}): - provider = "bitbucket" +type PRAttestationPayload struct { + *CommonAttestationPayload + GitProvider string `json:"git_provider"` + PullRequests []*types.PREvidence `json:"pull_requests"` +} + +type attestPROptions struct { + *CommonAttestationOptions + retriever interface{} + assert bool + payload PRAttestationPayload +} + +func (o *attestPROptions) getRetriever() types.PRRetriever { + return o.retriever.(types.PRRetriever) +} + +func (o *attestPROptions) run(args []string) error { + url := fmt.Sprintf("%s/api/v2/attestations/%s/%s/trail/%s/pull_request", global.Host, global.Org, o.flowName, o.trailName) + + err := o.CommonAttestationOptions.run(args, o.payload.CommonAttestationPayload) + if err != nil { + return err } - return provider, label + + pullRequestsEvidence, err := getPullRequestsEvidence(o.getRetriever(), o.payload.Commit.Sha1, o.assert) + if err != nil { + return err + } + + o.payload.PullRequests = pullRequestsEvidence + + label := "" + o.payload.GitProvider, label = getGitProviderAndLabel(o.retriever) + + form, cleanupNeeded, evidencePath, err := prepareAttestationForm(o.payload, o.evidencePaths) + if err != nil { + return err + } + // if we created a tar package, remove it after uploading it + if cleanupNeeded { + defer os.Remove(evidencePath) + } + + logger.Debug("found %d %s(s) for commit: %s\n", len(pullRequestsEvidence), label, o.payload.Commit.Sha1) + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + Form: form, + DryRun: global.DryRun, + Password: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("%s %s attestation '%s' is reported to trail: %s", o.payload.GitProvider, label, o.payload.AttestationName, o.trailName) + } + return err } type pullRequestCommitOptions struct { @@ -178,3 +220,21 @@ func getPullRequestsEvidence(retriever types.PRRetriever, commit string, assert } return pullRequestsEvidence, nil } + +func getGitProviderAndLabel(retriever interface{}) (string, string) { + label := "pull request" + provider := "" + t := reflect.TypeOf(retriever) + switch t { + case reflect.TypeOf(&gitlabUtils.GitlabConfig{}): + provider = "gitlab" + label = "merge request" + case reflect.TypeOf(&ghUtils.GithubConfig{}): + provider = "github" + case reflect.TypeOf(&azUtils.AzureConfig{}): + provider = "azure" + case reflect.TypeOf(&bbUtils.Config{}): + provider = "bitbucket" + } + return provider, label +} diff --git a/cmd/kosli/reportEvidenceArtifactPR.go b/cmd/kosli/reportEvidenceArtifactPR.go index 7656c64a8..f38636b5d 100644 --- a/cmd/kosli/reportEvidenceArtifactPR.go +++ b/cmd/kosli/reportEvidenceArtifactPR.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -const reportEvidenceArtifactPRDesc = `All Kosli commands to report pull/merge request commands.` +const reportEvidenceArtifactPRDesc = `All Kosli commands to report pull/merge request.` func newReportEvidenceArtifactPRCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 280fd54db..57449d950 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -54,6 +54,7 @@ The service principal needs to have the following permissions: // flags apiTokenFlag = "The Kosli API token." artifactName = "[optional] Artifact display name, if different from file, image or directory name." + artifactDisplayName = "[optional] Artifact display name, if different from file, image or directory name." orgFlag = "The Kosli organization." hostFlag = "[defaulted] The Kosli endpoint." dryRunFlag = "[optional] Run in dry-run mode. When enabled, no data is sent to Kosli and the CLI exits with 0 exit code regardless of any errors." @@ -63,6 +64,8 @@ The service principal needs to have the following permissions: debugFlag = "[optional] Print debug logs to stdout. A boolean flag https://docs.kosli.com/faq/#boolean-flags (default false)" artifactTypeFlag = "[conditional] The type of the artifact to calculate its SHA256 fingerprint. One of: [docker, file, dir]. Only required if you don't specify '--fingerprint'." flowNameFlag = "The Kosli flow name." + trailNameFlag = "The Kosli trail name." + templateArtifactName = "The name of the artifact in the yml template file." auditTrailNameFlag = "The Kosli audit trail name." workflowIDFlag = "The ID of the workflow." stepNameFlag = "The name of the step as defined in the audit trail's steps." @@ -91,13 +94,17 @@ The service principal needs to have the following permissions: jiraPATFlag = "Jira personal access token (for self-hosted Jira)" envDescriptionFlag = "[optional] The environment description." flowDescriptionFlag = "[optional] The Kosli flow description." + trailDescriptionFlag = "[optional] The Kosli trail description." workflowDescriptionFlag = "[optional] The Kosli Workflow description." visibilityFlag = "[defaulted] The visibility of the Kosli flow. Valid visibilities are [public, private]." templateFlag = "[defaulted] The comma-separated list of required compliance controls names." + templateFileFlag = "The path to a yaml template file." stepsFlag = "[defaulted] The comma-separated list of required audit trail steps names." - approvalUserDataFlag = "[optional] The path to a JSON file containing additional data you would like to attach to this approval." - evidenceUserDataFlag = "[optional] The path to a JSON file containing additional data you would like to attach to this evidence." - deploymentUserDataFlag = "[optional] The path to a JSON file containing additional data you would like to attach to this deployment." + approvalUserDataFlag = "[optional] The path to a JSON file containing additional data you would like to attach to the approval." + evidenceUserDataFlag = "[optional] The path to a JSON file containing additional data you would like to attach to the evidence." + attestationUserDataFlag = "[optional] The path to a JSON file containing additional data you would like to attach to the attestation." + deploymentUserDataFlag = "[optional] The path to a JSON file containing additional data you would like to attach to the deployment." + trailUserDataFlag = "[optional] The path to a JSON file containing additional data you would like to attach to the flow trail." gitCommitFlag = "The git commit from which the artifact was created. (defaulted in some CIs: https://docs.kosli.com/ci-defaults )." evidenceBuildUrlFlag = "The url of CI pipeline that generated the evidence. (defaulted in some CIs: https://docs.kosli.com/ci-defaults )." buildUrlFlag = "The url of CI pipeline that built the artifact. (defaulted in some CIs: https://docs.kosli.com/ci-defaults )." @@ -108,7 +115,7 @@ The service principal needs to have the following permissions: bbPasswordFlag = "Bitbucket App password. See https://developer.atlassian.com/cloud/bitbucket/rest/intro/#authentication for more details." bbWorkspaceFlag = "Bitbucket workspace ID." commitPREvidenceFlag = "Git commit for which to find pull request evidence. (defaulted in some CIs: https://docs.kosli.com/ci-defaults )." - commitEvidenceFlag = "Git commit for which to verify and given evidence. (defaulted in some CIs: https://docs.kosli.com/ci-defaults )." + commitEvidenceFlag = "Git commit for which to verify a given evidence. (defaulted in some CIs: https://docs.kosli.com/ci-defaults )." repositoryFlag = "Git repository. (defaulted in some CIs: https://docs.kosli.com/ci-defaults )." assertPREvidenceFlag = "[optional] Exit with non-zero code if no pull requests found for the given commit." assertJiraEvidenceFlag = "[optional] Exit with non-zero code if no jira issue reference found, or jira issue does not exist, for the given commit or branch." @@ -159,6 +166,14 @@ The service principal needs to have the following permissions: intervalFlag = "[optional] Expression to define specified snapshots range." showUnchangedArtifactsFlag = "[defaulted] Show the unchanged artifacts present in both snapshots within the diff output." approverFlag = "[optional] The user approving an approval." + attestationFingerprintFlag = "[optional] The SHA256 fingerprint of the artifact to attach the attestation to." + attestationCommitFlag = "The git commit associated to the attestation. (defaulted in some CIs: https://docs.kosli.com/ci-defaults )." + attestationUrlFlag = "The url pointing to where the attestation came from or is related. (defaulted to the CI url in some CIs: https://docs.kosli.com/ci-defaults )." + attestationNameFlag = "The name of the attestation as declared in the flow or trail yaml template." + attestationCompliantFlag = "[defaulted] Whether the attestation is compliant or not. A boolean flag https://docs.kosli.com/faq/#boolean-flags" + attestationRepoRootFlag = "[defaulted] The directory where the source git repository is available. Only used if --commit is used." + uploadJunitResultsFlag = "[defaulted] Whether to upload the provided Junit results directory as evidence to Kosli or not." + attestationAssertFlag = "[optional] Exit with non-zero code if the attestation is non-compliant" ) var global *GlobalOpts @@ -236,6 +251,8 @@ func newRootCmd(out io.Writer, args []string) (*cobra.Command, error) { // New syntax commands newGetCmd(out), newCreateCmd(out), + newBeginCmd(out), + newAttestCmd(out), newReportCmd(out), newDiffCmd(out), newAllowCmd(out), diff --git a/cmd/kosli/testHelpers.go b/cmd/kosli/testHelpers.go index 6c82f78dc..a98b2e3d8 100644 --- a/cmd/kosli/testHelpers.go +++ b/cmd/kosli/testHelpers.go @@ -146,6 +146,38 @@ func CreateFlow(flowName string, t *testing.T) { require.NoError(t, err, "flow should be created without error") } +// CreateFlowWithTemplate creates a flow with a yaml template on the server +func CreateFlowWithTemplate(flowName, templatePath string, t *testing.T) { + t.Helper() + o := &createFlowWithTemplateOptions{ + payload: FlowWithTemplatePayload{ + Name: flowName, + Description: "test flow", + Visibility: "private", + }, + TemplateFile: templatePath, + } + + err := o.run([]string{flowName}) + require.NoError(t, err, "flow should be created without error") +} + +// BeginTrail creates a trail with a yaml template on the server +func BeginTrail(trailName, flowName, templatePath string, t *testing.T) { + t.Helper() + o := &beginTrailOptions{ + payload: TrailPayload{ + Name: trailName, + Description: "test trail", + }, + templateFile: templatePath, + flow: flowName, + } + + err := o.run([]string{trailName}) + require.NoError(t, err, "trail should be begun without error") +} + func CreateAuditTrail(auditTrailName string, t *testing.T) { t.Helper() o := &createAuditTrailOptions{ @@ -205,6 +237,29 @@ func CreateArtifact(flowName, artifactFingerprint, artifactName string, t *testi require.NoError(t, err, "artifact should be created without error") } +// CreateArtifactOnTrail creates an artifact on a trail on the server +func CreateArtifactOnTrail(flowName, trailName, step_name, artifactFingerprint, artifactName string, t *testing.T) { + t.Helper() + o := &attestArtifactOptions{ + srcRepoRoot: "../..", + flowName: flowName, + gitReference: "0fc1ba9876f91b215679f3649b8668085d820ab5", + payload: AttestArtifactPayload{ + Fingerprint: artifactFingerprint, + GitCommit: "0fc1ba9876f91b215679f3649b8668085d820ab5", + BuildUrl: "www.yr.no", + CommitUrl: "www.nrk.no", + TrailName: trailName, + Name: step_name, + }, + } + + o.fingerprintOptions = new(fingerprintOptions) + + err := o.run([]string{artifactName}) + require.NoError(t, err, "artifact should be created without error") +} + func CreateArtifactWithCommit(flowName, artifactFingerprint, artifactName string, gitCommit string, t *testing.T) { t.Helper() o := &reportArtifactOptions{ diff --git a/cmd/kosli/testdata/invalid_template.yml b/cmd/kosli/testdata/invalid_template.yml new file mode 100644 index 000000000..a019b0858 --- /dev/null +++ b/cmd/kosli/testdata/invalid_template.yml @@ -0,0 +1,5 @@ +version: 1 +trail: + attestations: + - name: foo + type: not-supported \ No newline at end of file diff --git a/cmd/kosli/testdata/valid_template.yml b/cmd/kosli/testdata/valid_template.yml new file mode 100644 index 000000000..6c4ab0658 --- /dev/null +++ b/cmd/kosli/testdata/valid_template.yml @@ -0,0 +1,10 @@ +version: 1 +trail: + attestations: + - name: bar + type: generic + artifacts: + - name: cli + attestations: + - name: foo + type: generic \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 76619b539..5cc8318af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: server-index: networks: [ cli_net ] depends_on: [ mongo_rs_initiate, minio ] - image: 772819027869.dkr.ecr.eu-central-1.amazonaws.com/merkely:c8efd7a + image: 772819027869.dkr.ecr.eu-central-1.amazonaws.com/merkely:latest command: /app/src/documentdb/wait_till_ready_or_raise.py container_name: cli_kosli_server-index read_only: true @@ -32,10 +32,11 @@ services: condition: service_started server-index: condition: service_completed_successfully - image: 772819027869.dkr.ecr.eu-central-1.amazonaws.com/merkely:c8efd7a + image: 772819027869.dkr.ecr.eu-central-1.amazonaws.com/merkely:latest env_file: [ "./mongo/mongo.env" ] environment: KOSLI_HOSTNAME: localhost + TEST_MODE: cli EVIDENCE_BUCKET_ENDPOINT_URL: http://minio:9000 EVIDENCE_BUCKET_NAME: cli-tests-evidence AWS_ACCESS_KEY_ID: ROOTUSER diff --git a/go.mod b/go.mod index b9f54b6be..b05d40900 100644 --- a/go.mod +++ b/go.mod @@ -33,10 +33,10 @@ require ( github.com/xanzy/go-gitlab v0.81.0 github.com/xeonx/timeago v1.0.0-rc5 golang.org/x/oauth2 v0.7.0 - k8s.io/api v0.26.8 - k8s.io/apimachinery v0.26.8 + k8s.io/api v0.26.11 + k8s.io/apimachinery v0.26.11 k8s.io/client-go v1.5.2 - k8s.io/kubernetes v1.26.8 + k8s.io/kubernetes v1.26.11 sigs.k8s.io/kind v0.11.1 ) @@ -139,7 +139,7 @@ require ( github.com/subosito/gotenv v1.4.2 // indirect github.com/trivago/tgo v1.0.7 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.35.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.35.1 // indirect go.opentelemetry.io/otel v1.10.0 // indirect go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.10.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.10.0 // indirect @@ -149,25 +149,27 @@ require ( go.opentelemetry.io/otel/trace v1.10.0 // indirect go.opentelemetry.io/proto/otlp v0.19.0 // indirect golang.org/x/crypto v0.14.0 // indirect - golang.org/x/mod v0.9.0 // indirect + golang.org/x/mod v0.12.0 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/sys v0.13.0 // indirect golang.org/x/term v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect golang.org/x/time v0.3.0 // indirect - golang.org/x/tools v0.7.0 // indirect + golang.org/x/tools v0.12.0 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect + google.golang.org/genproto v0.0.0-20230525234025-438c736192d0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20230525234020-1aefcd67740a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19 // indirect google.golang.org/grpc v1.56.3 // indirect - google.golang.org/protobuf v1.30.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiserver v0.26.8 // indirect - k8s.io/component-base v0.26.8 // indirect - k8s.io/component-helpers v0.26.8 // indirect + k8s.io/apiserver v0.26.11 // indirect + k8s.io/component-base v0.26.11 // indirect + k8s.io/component-helpers v0.26.11 // indirect k8s.io/klog/v2 v2.90.1 // indirect k8s.io/kube-openapi v0.0.0-20230308215209-15aac26d736a // indirect k8s.io/kubectl v0.0.0 // indirect @@ -179,64 +181,64 @@ require ( sigs.k8s.io/yaml v1.3.0 // indirect ) -replace k8s.io/client-go => k8s.io/client-go v0.26.8 +replace k8s.io/client-go => k8s.io/client-go v0.26.11 -replace k8s.io/api => k8s.io/api v0.26.8 +replace k8s.io/api => k8s.io/api v0.26.11 -replace k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.26.8 +replace k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.26.11 -replace k8s.io/apimachinery => k8s.io/apimachinery v0.26.8 +replace k8s.io/apimachinery => k8s.io/apimachinery v0.26.11 -replace k8s.io/apiserver => k8s.io/apiserver v0.26.8 +replace k8s.io/apiserver => k8s.io/apiserver v0.26.11 -replace k8s.io/cli-runtime => k8s.io/cli-runtime v0.26.8 +replace k8s.io/cli-runtime => k8s.io/cli-runtime v0.26.11 -replace k8s.io/cloud-provider => k8s.io/cloud-provider v0.26.8 +replace k8s.io/cloud-provider => k8s.io/cloud-provider v0.26.11 -replace k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.26.8 +replace k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.26.11 -replace k8s.io/code-generator => k8s.io/code-generator v0.26.8 +replace k8s.io/code-generator => k8s.io/code-generator v0.26.11 -replace k8s.io/component-base => k8s.io/component-base v0.26.8 +replace k8s.io/component-base => k8s.io/component-base v0.26.11 -replace k8s.io/component-helpers => k8s.io/component-helpers v0.26.8 +replace k8s.io/component-helpers => k8s.io/component-helpers v0.26.11 -replace k8s.io/controller-manager => k8s.io/controller-manager v0.26.8 +replace k8s.io/controller-manager => k8s.io/controller-manager v0.26.11 -replace k8s.io/cri-api => k8s.io/cri-api v0.26.8 +replace k8s.io/cri-api => k8s.io/cri-api v0.26.11 -replace k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.26.8 +replace k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.26.11 -replace k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.26.8 +replace k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.26.11 -replace k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.26.8 +replace k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.26.11 -replace k8s.io/kube-proxy => k8s.io/kube-proxy v0.26.8 +replace k8s.io/kube-proxy => k8s.io/kube-proxy v0.26.11 -replace k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.26.8 +replace k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.26.11 -replace k8s.io/kubectl => k8s.io/kubectl v0.26.8 +replace k8s.io/kubectl => k8s.io/kubectl v0.26.11 -replace k8s.io/kubelet => k8s.io/kubelet v0.26.8 +replace k8s.io/kubelet => k8s.io/kubelet v0.26.11 -replace k8s.io/legacy-cloud-providers => k8s.io/legacy-cloud-providers v0.26.8 +replace k8s.io/legacy-cloud-providers => k8s.io/legacy-cloud-providers v0.26.11 -replace k8s.io/metrics => k8s.io/metrics v0.26.8 +replace k8s.io/metrics => k8s.io/metrics v0.26.11 -replace k8s.io/mount-utils => k8s.io/mount-utils v0.26.8 +replace k8s.io/mount-utils => k8s.io/mount-utils v0.26.11 -replace k8s.io/pod-security-admission => k8s.io/pod-security-admission v0.26.8 +replace k8s.io/pod-security-admission => k8s.io/pod-security-admission v0.26.11 -replace k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.26.8 +replace k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.26.11 -replace k8s.io/sample-cli-plugin => k8s.io/sample-cli-plugin v0.26.8 +replace k8s.io/sample-cli-plugin => k8s.io/sample-cli-plugin v0.26.11 -replace k8s.io/sample-controller => k8s.io/sample-controller v0.26.8 +replace k8s.io/sample-controller => k8s.io/sample-controller v0.26.11 replace github.com/opencontainers/image-spec => github.com/opencontainers/image-spec v1.0.2 replace github.com/opencontainers/runc => github.com/opencontainers/runc v1.1.2 -replace k8s.io/dynamic-resource-allocation => k8s.io/dynamic-resource-allocation v0.26.8 +replace k8s.io/dynamic-resource-allocation => k8s.io/dynamic-resource-allocation v0.26.11 -replace k8s.io/kms => k8s.io/kms v0.26.8 +replace k8s.io/kms => k8s.io/kms v0.26.11 diff --git a/go.sum b/go.sum index ffbf35181..6e0bd7ceb 100644 --- a/go.sum +++ b/go.sum @@ -660,8 +660,8 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.35.0 h1:Ajldaqhxqw/gNzQA45IKFWLdG7jZuXX/wBW1d5qvbUI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.35.0/go.mod h1:9NiG9I2aHTKkcxqCILhjtyNA1QEiCjdBACv4IvrFQ+c= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.35.1 h1:sxoY9kG1s1WpSYNyzm24rlwH4lnRYFXUVVBmKMBfRgw= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.35.1/go.mod h1:9NiG9I2aHTKkcxqCILhjtyNA1QEiCjdBACv4IvrFQ+c= go.opentelemetry.io/otel v1.10.0 h1:Y7DTJMR6zs1xkS/upamJYk0SxxN4C9AqRd77jmZnyY4= go.opentelemetry.io/otel v1.10.0/go.mod h1:NbvWjCthWHKBEUMpf0/v8ZRZlni86PpGFEMA9pnQSnQ= go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.10.0 h1:TaB+1rQhddO1sF71MpZOZAuSPW1klK2M8XxfrBMfK7Y= @@ -739,8 +739,8 @@ golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs= -golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -795,6 +795,7 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -823,8 +824,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -900,6 +901,7 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -909,6 +911,7 @@ golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -923,6 +926,7 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -988,8 +992,8 @@ golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= -golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= +golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss= +golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1061,8 +1065,12 @@ google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220107163113-42d7afdf6368/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= -google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= +google.golang.org/genproto v0.0.0-20230525234025-438c736192d0 h1:x1vNwUhVOcsYoKyEGCZBH694SBmmBjA2EfauFVEI2+M= +google.golang.org/genproto v0.0.0-20230525234025-438c736192d0/go.mod h1:9ExIQyXL5hZrHzQceCwuSYwZZ5QZBazOcprJ5rgs3lY= +google.golang.org/genproto/googleapis/api v0.0.0-20230525234020-1aefcd67740a h1:HiYVD+FGJkTo+9zj1gqz0anapsa1JxjiSrN+BJKyUmE= +google.golang.org/genproto/googleapis/api v0.0.0-20230525234020-1aefcd67740a/go.mod h1:ts19tUU+Z0ZShN1y3aPyq2+O3d5FUNNgT6FtOzmrNn8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19 h1:0nDDozoAU19Qb2HwhXadU8OcsiO/09cnTqhUtq2MEOM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1099,9 +1107,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -1144,18 +1151,18 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -k8s.io/api v0.26.8 h1:k2OtFmQPWfDUyAuYAwQPftVygF/vz4BMGSKnd15iddM= -k8s.io/api v0.26.8/go.mod h1:QaflR7cmG3V9lIz0VLBM+ylndNN897OAUAoJDcgwiQw= -k8s.io/apimachinery v0.26.8 h1:SzpGtRX3/j/Ylg8Eg65Iobpxi9Jz4vOvI0qcBZyPVrM= -k8s.io/apimachinery v0.26.8/go.mod h1:qYzLkrQ9lhrZRh0jNKo2cfvf/R1/kQONnSiyB7NUJU0= -k8s.io/apiserver v0.26.8 h1:N6y2rVkMo4q+ZJWjQOYYIPY/jlxqiNFsiAsrB6JjsoA= -k8s.io/apiserver v0.26.8/go.mod h1:rQ3thye841vuya4oxnvmPV6ZjlrJP3Ru7vEXRF/lAk8= -k8s.io/client-go v0.26.8 h1:pPuTYaVtLlg/7n6rqs3MsKLi4XgNaJ3rTMyS37Y5CKU= -k8s.io/client-go v0.26.8/go.mod h1:1sBQqKmdy9rWZYQnoedpc0gnRXG7kU3HrKZvBe2QbGM= -k8s.io/component-base v0.26.8 h1:j+W9y9id4CLW85+5GhRMgcYLaezw6bK+ZQ2eN3uZtJc= -k8s.io/component-base v0.26.8/go.mod h1:tOQmHjTJBLjzWLWqbxz7sVgX9XMMphEcy0tWhk+u2BI= -k8s.io/component-helpers v0.26.8 h1:y/gAdhXvJbY+lMxbShv51v3R8NhFlff7eFCGgGZfaoE= -k8s.io/component-helpers v0.26.8/go.mod h1:YvEk4fl8eROxoHfdQKyJ3TFrhg23i7juIy85beISmEA= +k8s.io/api v0.26.11 h1:hLhTZRdYc3vBBOY4wbEyTLWgMyieOAk2Ws9NG57QqO4= +k8s.io/api v0.26.11/go.mod h1:bSr/A0TKRt5W2OMDdexkM/ER1NxOxiQqNNFXW2nMZrM= +k8s.io/apimachinery v0.26.11 h1:w//840HHdwSRKqD15j9YX9HLlU6RPlfrvW0xEhLk2+0= +k8s.io/apimachinery v0.26.11/go.mod h1:2/HZp0l6coXtS26du1Bk36fCuAEr/lVs9Q9NbpBtd1Y= +k8s.io/apiserver v0.26.11 h1:JcrlATLu5xQVLV7/rfRFFl9ivvNLmZH0dM3DFFdFp+w= +k8s.io/apiserver v0.26.11/go.mod h1:htEG/Q3sI3+6Is3Z26QzBjaCGICsz/kFj+IhIP4oJuE= +k8s.io/client-go v0.26.11 h1:RjfZr5+vQjjTRmk4oCqHyC0cgrZXPjw+X+ge35sk4GI= +k8s.io/client-go v0.26.11/go.mod h1:+emNszw9va/uRJIM5ALTBtFnlZMTjwBrNjRfEh0iuw8= +k8s.io/component-base v0.26.11 h1:1/JmB6fexefGByfFyIK6aHksZZVtaDskttzXOzmZ6zA= +k8s.io/component-base v0.26.11/go.mod h1:jYNisnoM6iWFRUg51pxaQabzL5fBYTr5CMpsLjUYGp0= +k8s.io/component-helpers v0.26.11 h1:XD2/2lik/5n1WFepDvgHzIGL0tix/EU3GaxGJHdsgkA= +k8s.io/component-helpers v0.26.11/go.mod h1:lw3bchkI0NHMPmb+CE73GznPW0Mvqd/Y9UVMEqBkysE= k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= @@ -1165,12 +1172,12 @@ k8s.io/klog/v2 v2.90.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280/go.mod h1:+Axhij7bCpeqhklhUTe3xmOn6bWxolyZEeyaFpjGtl4= k8s.io/kube-openapi v0.0.0-20230308215209-15aac26d736a h1:gmovKNur38vgoWfGtP5QOGNOA7ki4n6qNYoFAgMlNvg= k8s.io/kube-openapi v0.0.0-20230308215209-15aac26d736a/go.mod h1:y5VtZWM9sHHc2ZodIH/6SHzXj+TPU5USoA8lcIeKEKY= -k8s.io/kubectl v0.26.8 h1:8252xsEUAlK1K0J1w+8pE8k/Xl4b4p1OC7S9Ib0AQxU= -k8s.io/kubectl v0.26.8/go.mod h1:zqblts62fYhUOeWKwNHr2KAh4Bf8TnTsbWKTXilELJQ= -k8s.io/kubernetes v1.26.8 h1:vC3oBFD2H8A1c7L0WFMWKQYN5xRJy93QOCoQWNe1CF8= -k8s.io/kubernetes v1.26.8/go.mod h1:EBE8dfGfk2sZ3yzZVQjr1wQ/k28/wwaajL/1+77Cjmg= -k8s.io/pod-security-admission v0.26.8 h1:HnCZk8Gz83xFp25nIJkUPaYg0NV3sFCTAqGmJiJlq+4= -k8s.io/pod-security-admission v0.26.8/go.mod h1:gzThscfIEyA1dPlCEuNoxWjo4C4QsqDI54rBA6x31MM= +k8s.io/kubectl v0.26.11 h1:cVPzYA4HKefU3tPiVK7hZpJ+5Lm04XoyvCCY5ODznpQ= +k8s.io/kubectl v0.26.11/go.mod h1:xjEX/AHtEQrGj2AGqVopyHr/JU1hLy1k7Yn48JuK9LQ= +k8s.io/kubernetes v1.26.11 h1:g3r1IAUqsaHnOG2jdpoagJ5W9UCXkR2ljQ/7BmCzPNg= +k8s.io/kubernetes v1.26.11/go.mod h1:z1URAaBJ+XnOTr3Q/l4umxRUxn/OyD2fbkUgS0Bl7u4= +k8s.io/pod-security-admission v0.26.11 h1:068J8MJeidJVRmmhHMZ+/Dp90PtJIJeZyrXJvK7Zrp8= +k8s.io/pod-security-admission v0.26.11/go.mod h1:pEUtkEyTwiS4YsPPU03BnP9kGoflPyrG53cgH2YMNHs= k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= k8s.io/utils v0.0.0-20221107191617-1a15be271d1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20230313181309-38a27ef9d749 h1:xMMXJlJbsU8w3V5N2FLDQ8YgU8s1EoULdbQBcAeNJkY= diff --git a/internal/gitview/gitView.go b/internal/gitview/gitView.go index ad8a1d6f5..c330c8568 100644 --- a/internal/gitview/gitView.go +++ b/internal/gitview/gitView.go @@ -11,13 +11,17 @@ import ( "github.com/kosli-dev/cli/internal/logger" ) +type BasicCommitInfo struct { + Sha1 string `json:"sha1"` + Message string `json:"message"` + Author string `json:"author"` + Timestamp int64 `json:"timestamp"` + Branch string `json:"branch"` +} + type CommitInfo struct { - Sha1 string `json:"sha1"` - Message string `json:"message"` - Author string `json:"author"` - Timestamp int64 `json:"timestamp"` - Branch string `json:"branch"` - Parents []string `json:"parents"` + BasicCommitInfo + Parents []string `json:"parents"` } // GitView @@ -181,12 +185,14 @@ func asCommitInfo(commit *object.Commit, branchName string) *CommitInfo { commitParents = append(commitParents, hash.String()) } return &CommitInfo{ - Sha1: commit.Hash.String(), - Message: strings.TrimSpace(commit.Message), - Author: commit.Author.String(), - Timestamp: commit.Author.When.UTC().Unix(), - Branch: branchName, - Parents: commitParents, + BasicCommitInfo: BasicCommitInfo{ + Sha1: commit.Hash.String(), + Message: strings.TrimSpace(commit.Message), + Author: commit.Author.String(), + Timestamp: commit.Author.When.UTC().Unix(), + Branch: branchName, + }, + Parents: commitParents, } } diff --git a/internal/testHelpers/testHelpers.go b/internal/testHelpers/testHelpers.go index 579bb1baf..17d73424d 100644 --- a/internal/testHelpers/testHelpers.go +++ b/internal/testHelpers/testHelpers.go @@ -2,13 +2,14 @@ package testHelpers import ( "fmt" - "github.com/go-git/go-git/v5/plumbing" - "github.com/stretchr/testify/require" "os" "path/filepath" "testing" "time" + "github.com/go-git/go-git/v5/plumbing" + "github.com/stretchr/testify/require" + "github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5/osfs" "github.com/go-git/go-git/v5" @@ -34,6 +35,15 @@ func GithubCommitWithPR() string { return "e21a8afff429e0c87ee523d683f2438113f0a105" } +func CloneGitRepo(url, cloneTo string) (*git.Repository, error) { + // the repo worktree filesystem. It has to be osfs so that we can give it a path + fs := osfs.New(cloneTo) + // the filesystem for git database + storerFS := osfs.New(filepath.Join(cloneTo, ".git")) + storer := filesystem.NewStorage(storerFS, cache.NewObjectLRUDefault()) + return git.Clone(storer, fs, &git.CloneOptions{URL: url}) +} + func InitializeGitRepo(repoPath string) (*git.Repository, *git.Worktree, billy.Filesystem, error) { // the repo worktree filesystem. It has to be osfs so that we can give it a path fs := osfs.New(repoPath)