Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 42 additions & 12 deletions cmd/cli/app/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@
package app

import (
"context"
"errors"
"fmt"

"github.com/spf13/cobra"
"github.com/spf13/viper"
"google.golang.org/grpc"

"github.com/mindersec/minder/internal/util/cli"
"github.com/mindersec/minder/pkg/api"
Expand All @@ -21,21 +20,39 @@ var applyCmd = &cobra.Command{
Use: "apply",
Short: "Apply multiple minder resources",
Long: `The apply subcommand lets you apply multiple Minder resources at once.`,
RunE: cli.GRPCClientWrapRunE(applyCommand),
PreRunE: func(cmd *cobra.Command, _ []string) error {
if err := viper.BindPFlags(cmd.Flags()); err != nil {
return fmt.Errorf("error binding flags: %s", err)
}

return nil

},
RunE: applyCommand,
}

// applyCommand is the general-purpose "apply" subcommand
func applyCommand(ctx context.Context, cmd *cobra.Command, args []string, conn *grpc.ClientConn) error {
//
//nolint:gocyclo
func applyCommand(cmd *cobra.Command, args []string) error {
// Step 1: Collect inputs, by reading files or directories. Use the "-f" flag if set, positional arguments otherwise.
fileNames := args
if len(viper.GetStringSlice("file")) > 0 {
fileNames = viper.GetStringSlice("file")
argFiles, _ := cmd.Flags().GetStringSlice("file")
if len(argFiles) > 0 {
fileNames = argFiles
}
objects, err := fileconvert.ResourcesFromPaths(cmd.Printf, fileNames...)
if err != nil {
return cli.MessageAndError("Error reading resources", err)
}

if len(objects) == 0 {
return errors.New("no resources found")
}
// No longer print usage on returned error, since we've parsed our inputs
// See https://github.com/spf13/cobra/issues/340#issuecomment-374617413
cmd.SilenceUsage = true

// Step 2: sort objects by type
var profiles []*minderv1.Profile
var ruleTypes []*minderv1.RuleType
Expand Down Expand Up @@ -67,23 +84,35 @@ func applyCommand(ctx context.Context, cmd *cobra.Command, args []string, conn *
}

// Step 3: apply objects, starting with DataSources
dataSourceClient := minderv1.NewDataSourceServiceClient(conn)
dataSourceClient, dsClose, err := cli.GetCLIClient(cmd, minderv1.NewDataSourceServiceClient)
if err != nil {
return cli.MessageAndError("Error connecting to server", err)
}
defer dsClose()
for _, dataSource := range dataSources {
if err := api.UpsertDataSource(ctx, dataSourceClient, dataSource); err != nil {
if err := api.UpsertDataSource(cmd.Context(), dataSourceClient, dataSource); err != nil {
return cli.MessageAndError(fmt.Sprintf("Unable to create datasource %s", dataSource.Name), err)
}
}

ruleTypeClient := minderv1.NewRuleTypeServiceClient(conn)
ruleTypeClient, rtClose, err := cli.GetCLIClient(cmd, minderv1.NewRuleTypeServiceClient)
if err != nil {
return cli.MessageAndError("Error connecting to server", err)
}
defer rtClose()
for _, ruleType := range ruleTypes {
if err := api.UpsertRuleType(ctx, ruleTypeClient, ruleType); err != nil {
if err := api.UpsertRuleType(cmd.Context(), ruleTypeClient, ruleType); err != nil {
return cli.MessageAndError(fmt.Sprintf("Unable to create ruletype %s", ruleType.Name), err)
}
}

profileClient := minderv1.NewProfileServiceClient(conn)
profileClient, pClose, err := cli.GetCLIClient(cmd, minderv1.NewProfileServiceClient)
if err != nil {
return cli.MessageAndError("Error connecting to server", err)
}
defer pClose()
for _, profile := range profiles {
if err := api.UpsertProfile(ctx, profileClient, profile); err != nil {
if err := api.UpsertProfile(cmd.Context(), profileClient, profile); err != nil {
return cli.MessageAndError(fmt.Sprintf("Unable to create profile %s", profile.Name), err)
}
}
Expand All @@ -94,5 +123,6 @@ func applyCommand(ctx context.Context, cmd *cobra.Command, args []string, conn *
func init() {
RootCmd.AddCommand(applyCmd)
// Flags
applyCmd.Flags().StringP("project", "j", "", "ID of the project")
applyCmd.Flags().StringSliceP("file", "f", []string{}, "Input file or directory")
}
94 changes: 94 additions & 0 deletions cmd/cli/app/apply_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// SPDX-FileCopyrightText: Copyright 2023 The Minder Authors
// SPDX-License-Identifier: Apache-2.0

package app

import (
"context"
"path/filepath"
"testing"

"go.uber.org/mock/gomock"

"github.com/mindersec/minder/internal/util/cli"
minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
mockv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1/mock"
)

//nolint:paralleltest // Cannot run in parallel because it swaps global Viper/Stdout state
func TestApplyCommand(t *testing.T) {
tests := []cli.CmdTestCase{
{
Name: "apply - create profile and ruletypes with positional arg",
Args: []string{"apply", "fixture"},
MockSetup: func(t *testing.T, ctrl *gomock.Controller) context.Context {
t.Helper()
dsClient := mockv1.NewMockDataSourceServiceClient(ctrl)
rtClient := mockv1.NewMockRuleTypeServiceClient(ctrl)
pClient := mockv1.NewMockProfileServiceClient(ctrl)
mockRTResp := &minderv1.ListRuleTypesResponse{}
mockProfileResp := &minderv1.ListProfilesResponse{}
cli.LoadFixture(t, "mock_ruletypes_response.json", mockRTResp)
cli.LoadFixture(t, "mock_profiles_response.json", mockProfileResp)

rtClient.EXPECT().
CreateRuleType(gomock.Any(), gomock.Any()).
Return(&minderv1.CreateRuleTypeResponse{RuleType: mockRTResp.RuleTypes[0]}, nil)
rtClient.EXPECT().
CreateRuleType(gomock.Any(), gomock.Any()).
Return(&minderv1.CreateRuleTypeResponse{RuleType: mockRTResp.RuleTypes[1]}, nil)
pClient.EXPECT().
CreateProfile(gomock.Any(), gomock.Any()).
Return(&minderv1.CreateProfileResponse{Profile: mockProfileResp.Profiles[0]}, nil)
ctx := context.Background()
ctx = cli.WithRPCClient[minderv1.DataSourceServiceClient](ctx, dsClient)
ctx = cli.WithRPCClient[minderv1.RuleTypeServiceClient](ctx, rtClient)
ctx = cli.WithRPCClient[minderv1.ProfileServiceClient](ctx, pClient)
return ctx
},
GoldenFileName: "apply_create.table",
},
{
Name: "apply - create profile and ruletypes with flag",
Args: []string{"apply", "-f", "fixture/"},
MockSetup: func(t *testing.T, ctrl *gomock.Controller) context.Context {
t.Helper()
dsClient := mockv1.NewMockDataSourceServiceClient(ctrl)
rtClient := mockv1.NewMockRuleTypeServiceClient(ctrl)
pClient := mockv1.NewMockProfileServiceClient(ctrl)
mockRTResp := &minderv1.ListRuleTypesResponse{}
mockProfileResp := &minderv1.ListProfilesResponse{}
cli.LoadFixture(t, "mock_ruletypes_response.json", mockRTResp)
cli.LoadFixture(t, "mock_profiles_response.json", mockProfileResp)

rtClient.EXPECT().
CreateRuleType(gomock.Any(), gomock.Any()).
Return(&minderv1.CreateRuleTypeResponse{RuleType: mockRTResp.RuleTypes[0]}, nil)
rtClient.EXPECT().
CreateRuleType(gomock.Any(), gomock.Any()).
Return(&minderv1.CreateRuleTypeResponse{RuleType: mockRTResp.RuleTypes[1]}, nil)
pClient.EXPECT().
CreateProfile(gomock.Any(), gomock.Any()).
Return(&minderv1.CreateProfileResponse{Profile: mockProfileResp.Profiles[0]}, nil)
ctx := context.Background()
ctx = cli.WithRPCClient[minderv1.DataSourceServiceClient](ctx, dsClient)
ctx = cli.WithRPCClient[minderv1.RuleTypeServiceClient](ctx, rtClient)
ctx = cli.WithRPCClient[minderv1.ProfileServiceClient](ctx, pClient)
return ctx
},
GoldenFileName: "apply_create.table",
},
{
Name: "no files specified",
Args: []string{"apply"},
ExpectedError: "no resources found",
},
{
Name: "only empty files",
Args: []string{"apply", filepath.Join("fixture", "empty_file.yaml")},
ExpectedError: "no resources found",
},
}

cli.RunCmdTests(t, tests, applyCmd)
}
35 changes: 35 additions & 0 deletions cmd/cli/app/fixture/applied_rule.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# SPDX-FileCopyrightText: Copyright 2026 The Minder Authors
# SPDX-License-Identifier: Apache-2.0

package minder

# METADATA
#
# title: 'Applied Rule'
# description: 'A minimal test rule type.'
# custom:
# release_phase: alpha
# severity:
# value: low
# guidance: Do it!
# def:
# in_entity: repository
#
# # How to gather data (Minimal REST example)
# ingest:
# type: rest
# rest:
# endpoint: '/repos/{{.Entity.Owner}}/{{.Entity.Name}}'
# parse: 'json'
#
# eval:
# rego:
# type: 'deny-by-default'
package minder

import rego.v1

# How to evaluate the data (Minimal Rego example that always passes)
default allow := false

allow := true
4 changes: 4 additions & 0 deletions cmd/cli/app/fixture/empty_file.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2026 The Minder Authors
# SPDX-License-Identifier: Apache-2.0

# (boilerplate required by presubmit checks)
60 changes: 60 additions & 0 deletions cmd/cli/app/fixture/mock_profiles_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"profiles": [
{
"context": {
"project": "00000000-0000-0000-0000-000000000000"
},
"id": "11111111-1111-1111-1111-111111111111",
"name": "mock-artifact-profile",
"artifact": [
{
"type": "artifact_signature",
"params": {
"name": "mock-artifact",
"tags": [
"latest"
]
},
"def": {
"is_signed": true,
"is_verified": true
},
"name": "Mock ensure artifacts are signed"
}
],
"remediate": "off",
"alert": "on",
"displayName": "Mock Artifact Signature Profile"
},
{
"context": {
"project": "00000000-0000-0000-0000-000000000000"
},
"id": "22222222-2222-2222-2222-222222222222",
"name": "mock-branch-protection",
"repository": [
{
"type": "branch_protection_enabled",
"params": {
"branch": "main"
},
"def": {},
"name": "Mock enable branch protection"
},
{
"type": "branch_protection_require_pull_request_approving_review_count",
"params": {
"branch": "main"
},
"def": {
"required_approving_review_count": 2
},
"name": "Mock require 2 reviews"
}
],
"remediate": "on",
"alert": "off",
"displayName": "Mock Branch Protection Profile"
}
]
}
79 changes: 79 additions & 0 deletions cmd/cli/app/fixture/mock_ruletypes_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{
"ruleTypes": [
{
"id": "00000000-0000-0000-0000-000000000001",
"name": "applied_rule",
"displayName": "Applied Rule",
"context": {
"provider": "",
"project": "00000000-0000-0000-0000-000000000000"
},
"def": {
"inEntity": "repository",
"ruleSchema": {
},
"ingest": {
"type": "rest",
"rest": {
"endpoint": "/repos/{{.Entity.Owner}}/{{.Entity.Name}}",
"parse": "json"
}
},
"eval": {
"type": "rego",
"rego": {
"type": "deny-by-default",
"def": "package minder\n\n# METADATA\n# \n# title: 'Applied Rule'\n# ...\nimport rego.v1\n\ndefault allow := false\n\nallow := ntrue\n"
}
}
},
"description": "A minimal test rule type",
"guidance": "Do it!",
"severity": {
"value": "VALUE_LOW"
},
"releasePhase": "RULE_TYPE_RELEASE_PHASE_BETA"
},
{
"id": "00000000-0000-0000-0000-000000000002",
"name": "branch_protection_reviews",
"displayName": "Require Branch Protection Reviews",
"shortFailureMessage": "Secret scanning is not enabled",
"context": {
"provider": "",
"project": "00000000-0000-0000-0000-000000000000"
},
"def": {
"inEntity": "repository",
"ruleSchema": {
"properties": {
"required_reviews": {
"default": 2,
"type": "integer"
}
}
},
"ingest": {
"type": "rest",
"rest": {
"endpoint": "/repos/{{.Entity.Owner}}/{{.Entity.Name}}/branches/main/protection",
"parse": "json"
}
},
"eval": {
"type": "rego",
"rego": {
"type": "deny-by-default",
"def": "package minder\n\nimport rego.v1\n\ndefault allow := false\n\nallow if{\n #Check if the required review count meets our minimum\n input.ingested.required_pull_request_reviews.required_approving_review_count >= input.profile.required_reviews\n}\n"
}
}
},
"description": "Ensures that branch protection requires at least two approved reviews.",
"guidance": "To comply with this rule, you must enable branch protection on your main branch \nand set the 'Required approving reviews' count to 2 or higher.\n",
"severity": {
"value": "VALUE_HIGH"
},
"releasePhase": "RULE_TYPE_RELEASE_PHASE_BETA"
}
]
}
Loading