From ee93be00c718522171f99ff1eecfaad55660bed1 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Wed, 9 Jul 2025 14:05:25 +0200 Subject: [PATCH 1/6] feat(policy): add develop init Signed-off-by: Sylwester Piskozub --- app/cli/cmd/policy.go | 30 +++ app/cli/cmd/policy_develop.go | 31 +++ app/cli/cmd/policy_develop_init.go | 79 ++++++++ app/cli/cmd/root.go | 2 +- app/cli/documentation/cli-reference.mdx | 172 +++++++++++++++++ .../internal/action/policy_develop_init.go | 58 ++++++ app/cli/internal/policy/init/init.go | 180 ++++++++++++++++++ .../policy/init/templates/example-policy.rego | 39 ++++ .../policy/init/templates/example-policy.yaml | 20 ++ 9 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 app/cli/cmd/policy.go create mode 100644 app/cli/cmd/policy_develop.go create mode 100644 app/cli/cmd/policy_develop_init.go create mode 100644 app/cli/internal/action/policy_develop_init.go create mode 100644 app/cli/internal/policy/init/init.go create mode 100644 app/cli/internal/policy/init/templates/example-policy.rego create mode 100644 app/cli/internal/policy/init/templates/example-policy.yaml diff --git a/app/cli/cmd/policy.go b/app/cli/cmd/policy.go new file mode 100644 index 000000000..db34a4425 --- /dev/null +++ b/app/cli/cmd/policy.go @@ -0,0 +1,30 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func newPolicyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "policy", + Short: "Craft chainloop policies", + } + + cmd.AddCommand(newPolicyDevelopCmd()) + return cmd +} diff --git a/app/cli/cmd/policy_develop.go b/app/cli/cmd/policy_develop.go new file mode 100644 index 000000000..a548167c6 --- /dev/null +++ b/app/cli/cmd/policy_develop.go @@ -0,0 +1,31 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func newPolicyDevelopCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "develop", + Aliases: []string{"dev"}, + Short: "Tools for policy development", + } + + cmd.AddCommand(newPolicyDevelopInitCmd()) + return cmd +} diff --git a/app/cli/cmd/policy_develop_init.go b/app/cli/cmd/policy_develop_init.go new file mode 100644 index 000000000..5d4f56206 --- /dev/null +++ b/app/cli/cmd/policy_develop_init.go @@ -0,0 +1,79 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "fmt" + + "github.com/chainloop-dev/chainloop/app/cli/internal/action" + "github.com/spf13/cobra" +) + +func newPolicyDevelopInitCmd() *cobra.Command { + var ( + force bool + embedded bool + name string + description string + ) + + cmd := &cobra.Command{ + Use: "init [directory]", + Short: "Initialize a new policy", + Long: `Initialize a new policy by creating template policy files in the specified directory. +By default, it creates chainloop-policy.yaml and chainloop-policy.rego files.`, + Example: ` + # Initialize in current directory with separate files + chainloop policy develop init + + # Initialize in specific directory with embedded format + chainloop policy develop init ./policies --embedded`, + RunE: func(cmd *cobra.Command, args []string) error { + // Default to current directory if not specified + dir := "." + if len(args) > 0 { + dir = args[0] + } + + opts := &action.PolicyInitOpts{ + Force: force, + Embedded: embedded, + Name: name, + Description: description, + } + + policyInit, err := action.NewPolicyInit(opts, actionOpts) + if err != nil { + return fmt.Errorf("failed to initialize policy: %w", err) + } + + if err := policyInit.Run(cmd.Context(), dir); err != nil { + return newGracefulError(err) + } + + logger.Info().Msg("Initialized policy files") + + return nil + }, + } + + cmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite existing files") + cmd.Flags().BoolVar(&embedded, "embedded", false, "initialize an embedded policy (single YAML file)") + cmd.Flags().StringVar(&name, "name", "", "name of the policy") + cmd.Flags().StringVar(&description, "description", "", "description of the policy") + + return cmd +} diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 7e2e2422a..558f8477c 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -242,7 +242,7 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command { rootCmd.AddCommand(newWorkflowCmd(), newAuthCmd(), NewVersionCmd(), newAttestationCmd(), newArtifactCmd(), newConfigCmd(), newIntegrationCmd(), newOrganizationCmd(), newCASBackendCmd(), - newReferrerDiscoverCmd(), + newReferrerDiscoverCmd(), newPolicyCmd(), ) // Load plugins if we are not running a subcommand diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 71da46184..ef220fd02 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -2700,6 +2700,178 @@ Options inherited from parent commands -y, --yes Skip confirmation ``` +## chainloop policy + +Craft chainloop policies + +Options + +``` +-h, --help help for policy +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop policy develop + +Tools for policy development + +Options + +``` +-h, --help help for develop +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +#### chainloop policy develop help + +Help about any command + +Synopsis + +Help provides help for any command in the application. +Simply type develop help [path to command] for full details. + +``` +chainloop policy develop help [command] [flags] +``` + +Options + +``` +-h, --help help for help +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +#### chainloop policy develop init + +Initialize a new policy + +Synopsis + +Initialize a new policy by creating template policy files in the specified directory. +By default, it creates chainloop-policy.yaml and chainloop-policy.rego files. + +``` +chainloop policy develop init [directory] [flags] +``` + +Examples + +``` + +Initialize in current directory with separate files +chainloop policy develop init + +Initialize in specific directory with embedded format +chainloop policy develop init ./policies --embedded +``` + +Options + +``` +--description string description of the policy +--embedded initialize an embedded policy (single YAML file) +-f, --force overwrite existing files +-h, --help help for init +--name string name of the policy +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop policy help + +Help about any command + +Synopsis + +Help provides help for any command in the application. +Simply type policy help [path to command] for full details. + +``` +chainloop policy help [command] [flags] +``` + +Options + +``` +-h, --help help for help +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + ## chainloop version Command line version diff --git a/app/cli/internal/action/policy_develop_init.go b/app/cli/internal/action/policy_develop_init.go new file mode 100644 index 000000000..b60adb2c7 --- /dev/null +++ b/app/cli/internal/action/policy_develop_init.go @@ -0,0 +1,58 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "context" + "fmt" + + policy "github.com/chainloop-dev/chainloop/app/cli/internal/policy/init" +) + +type PolicyInitOpts struct { + Force bool + Embedded bool + Name string + Description string +} + +type PolicyInit struct { + *ActionsOpts + opts *PolicyInitOpts +} + +func NewPolicyInit(opts *PolicyInitOpts, actionOpts *ActionsOpts) (*PolicyInit, error) { + return &PolicyInit{ + ActionsOpts: actionOpts, + opts: opts, + }, nil +} + +func (action *PolicyInit) Run(_ context.Context, dir string) error { + initOpts := &policy.InitOptions{ + Dir: dir, + Embedded: action.opts.Embedded, + Force: action.opts.Force, + Name: action.opts.Name, + Description: action.opts.Description, + } + + if err := policy.Initialize(initOpts); err != nil { + return fmt.Errorf("initializing policy: %w", err) + } + + return nil +} diff --git a/app/cli/internal/policy/init/init.go b/app/cli/internal/policy/init/init.go new file mode 100644 index 000000000..5c0bb98dc --- /dev/null +++ b/app/cli/internal/policy/init/init.go @@ -0,0 +1,180 @@ +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "bytes" + "embed" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +//go:embed templates/* +var templateFS embed.FS + +const ( + policyTemplateRegoPath = "templates/example-policy.rego" + policyTemplatePath = "templates/example-policy.yaml" + defaultPolicyName = "chainloop-policy" + defaultPolicyDescription = "Chainloop validation policy" + defaultMaterialKind = "SBOM_CYCLONEDX_JSON" +) + +type TemplateData struct { + Name string + Description string + RegoPath string + RegoContent string + Embedded bool + MaterialKind string +} + +type Content struct { + YAML string + Rego string +} + +type InitOptions struct { + Dir string + Embedded bool + Force bool + Name string + Description string +} + +func Initialize(opts *InitOptions) error { + content, err := loadAndProcessTemplates(opts) + if err != nil { + return fmt.Errorf("failed to process templates: %w", err) + } + + files := make(map[string]string) + fileNameBase := sanitizeName(getPolicyName(opts.Name)) + + if opts.Embedded { + files[fileNameBase+".yaml"] = content.YAML + } else { + files[fileNameBase+".yaml"] = content.YAML + files[fileNameBase+".rego"] = content.Rego + } + + return writeFiles(opts.Dir, files, opts.Force) +} + +func getPolicyName(name string) string { + if name == "" { + return defaultPolicyName + } + return name +} + +func getPolicyDescription(description string) string { + if description == "" { + return defaultPolicyDescription + } + return description +} + +func loadAndProcessTemplates(opts *InitOptions) (*Content, error) { + regoContent, err := templateFS.ReadFile(policyTemplateRegoPath) + if err != nil { + return nil, fmt.Errorf("failed to read Rego template: %w", err) + } + + data := &TemplateData{ + Name: getPolicyName(opts.Name), + Description: getPolicyDescription(opts.Description), + RegoPath: sanitizeName(getPolicyName(opts.Name)) + ".rego", + RegoContent: string(regoContent), + Embedded: opts.Embedded, + MaterialKind: defaultMaterialKind, + } + + // Process main template + content, err := templateFS.ReadFile(policyTemplatePath) + if err != nil { + return nil, fmt.Errorf("failed to read policy template: %w", err) + } + + yamlContent, err := executeTemplate(string(content), data) + if err != nil { + return nil, fmt.Errorf("failed to execute template: %w", err) + } + + // For non-embedded case, we still need the Rego content to write to file + if !opts.Embedded { + return &Content{ + YAML: yamlContent, + Rego: data.RegoContent, + }, nil + } + + return &Content{YAML: yamlContent}, nil +} + +// Add custom template functions +func executeTemplate(content string, data *TemplateData) (string, error) { + tmpl := template.New("policy").Funcs(template.FuncMap{ + "sanitize": sanitizeName, + "trimSpace": strings.TrimSpace, + "indent": func(spaces int, s string) string { + pad := strings.Repeat(" ", spaces) + return pad + strings.ReplaceAll(s, "\n", "\n"+pad) + }, + }) + + tmpl, err := tmpl.Parse(content) + if err != nil { + return "", fmt.Errorf("template parsing error: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("template execution error: %w", err) + } + + return buf.String(), nil +} + +func sanitizeName(name string) string { + return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(name), " ", "-")) +} + +func writeFiles(dir string, files map[string]string, force bool) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + for filename, content := range files { + path := filepath.Join(dir, filename) + if !force && fileExists(path) { + return fmt.Errorf("file %s already exists (use --force to overwrite)", path) + } + + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + return fmt.Errorf("failed to write file %s: %w", filename, err) + } + } + + return nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return !os.IsNotExist(err) +} diff --git a/app/cli/internal/policy/init/templates/example-policy.rego b/app/cli/internal/policy/init/templates/example-policy.rego new file mode 100644 index 000000000..cb1edcb03 --- /dev/null +++ b/app/cli/internal/policy/init/templates/example-policy.rego @@ -0,0 +1,39 @@ +package main + +import rego.v1 + +################################ +# Common section do NOT change # +################################ + +result := { + "skipped": skipped, + "violations": violations, + "skip_reason": skip_reason, +} + +default skip_reason := "" + +skip_reason := m if { + not valid_input + m := "invalid input" +} + +default skipped := true + +skipped := false if valid_input + +######################################## +# EO Common section, custom code below # +######################################## + +# Validates if the input is valid and can be understood by this policy +valid_input if { + # insert code here +} + +# If the input is valid, check for any policy violation here +violations contains msg if { + valid_input + # insert code here +} diff --git a/app/cli/internal/policy/init/templates/example-policy.yaml b/app/cli/internal/policy/init/templates/example-policy.yaml new file mode 100644 index 000000000..576fae035 --- /dev/null +++ b/app/cli/internal/policy/init/templates/example-policy.yaml @@ -0,0 +1,20 @@ +apiVersion: policy.chainloop.dev/v1 +kind: ValidationPolicy +metadata: + name: {{.Name | sanitize}} + description: {{.Description | trimSpace}} +spec: + policies: + # Type of artifact to validate + # See docs: https://docs.chainloop.dev/concepts/material-types + - kind: {{.MaterialKind}} + {{if .Embedded -}} + # Embedded Rego policy + # See docs: https://docs.chainloop.dev/guides/custom-policies#embedded-vs-external + rego: | +{{.RegoContent | indent 8}} + {{else -}} + # Path to external Rego policy file + # See docs: https://docs.chainloop.dev/guides/custom-policies#rego-policy-structure + path: {{.RegoPath}} + {{end -}} \ No newline at end of file From 0823855f98359645fff31fcfe8133a238040a4e3 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Mon, 14 Jul 2025 14:35:42 +0200 Subject: [PATCH 2/6] fix: use directory flag instead of arg; add docs url; change policy package structure Signed-off-by: Sylwester Piskozub --- app/cli/cmd/policy_develop.go | 6 ++++-- app/cli/cmd/policy_develop_init.go | 21 +++++++++---------- .../internal/action/policy_develop_init.go | 8 +++---- .../{policy/init => policydevel}/init.go | 4 ++-- .../templates/example-policy.rego | 0 .../templates/example-policy.yaml | 0 6 files changed, 20 insertions(+), 19 deletions(-) rename app/cli/internal/{policy/init => policydevel}/init.go (98%) rename app/cli/internal/{policy/init => policydevel}/templates/example-policy.rego (100%) rename app/cli/internal/{policy/init => policydevel}/templates/example-policy.yaml (100%) diff --git a/app/cli/cmd/policy_develop.go b/app/cli/cmd/policy_develop.go index a548167c6..1d38ce88c 100644 --- a/app/cli/cmd/policy_develop.go +++ b/app/cli/cmd/policy_develop.go @@ -22,8 +22,10 @@ import ( func newPolicyDevelopCmd() *cobra.Command { cmd := &cobra.Command{ Use: "develop", - Aliases: []string{"dev"}, - Short: "Tools for policy development", + Aliases: []string{"devel"}, + Short: `Tools for policy development +Refer to https://docs.chainloop.dev/guides/custom-policies +`, } cmd.AddCommand(newPolicyDevelopInitCmd()) diff --git a/app/cli/cmd/policy_develop_init.go b/app/cli/cmd/policy_develop_init.go index 5d4f56206..6d65529b7 100644 --- a/app/cli/cmd/policy_develop_init.go +++ b/app/cli/cmd/policy_develop_init.go @@ -28,10 +28,11 @@ func newPolicyDevelopInitCmd() *cobra.Command { embedded bool name string description string + directory string ) cmd := &cobra.Command{ - Use: "init [directory]", + Use: "init", Short: "Initialize a new policy", Long: `Initialize a new policy by creating template policy files in the specified directory. By default, it creates chainloop-policy.yaml and chainloop-policy.rego files.`, @@ -39,20 +40,18 @@ By default, it creates chainloop-policy.yaml and chainloop-policy.rego files.`, # Initialize in current directory with separate files chainloop policy develop init - # Initialize in specific directory with embedded format - chainloop policy develop init ./policies --embedded`, - RunE: func(cmd *cobra.Command, args []string) error { - // Default to current directory if not specified - dir := "." - if len(args) > 0 { - dir = args[0] + # Initialize in specific directory with embedded format and policy name + chainloop policy develop init --directory ./policies --embedded --name mypolicy`, + RunE: func(cmd *cobra.Command, _ []string) error { + if directory == "" { + directory = "." } - opts := &action.PolicyInitOpts{ Force: force, Embedded: embedded, Name: name, Description: description, + Directory: directory, } policyInit, err := action.NewPolicyInit(opts, actionOpts) @@ -60,7 +59,7 @@ By default, it creates chainloop-policy.yaml and chainloop-policy.rego files.`, return fmt.Errorf("failed to initialize policy: %w", err) } - if err := policyInit.Run(cmd.Context(), dir); err != nil { + if err := policyInit.Run(); err != nil { return newGracefulError(err) } @@ -74,6 +73,6 @@ By default, it creates chainloop-policy.yaml and chainloop-policy.rego files.`, cmd.Flags().BoolVar(&embedded, "embedded", false, "initialize an embedded policy (single YAML file)") cmd.Flags().StringVar(&name, "name", "", "name of the policy") cmd.Flags().StringVar(&description, "description", "", "description of the policy") - + cmd.Flags().StringVar(&directory, "directory", "", "directory for policy") return cmd } diff --git a/app/cli/internal/action/policy_develop_init.go b/app/cli/internal/action/policy_develop_init.go index b60adb2c7..2131abca6 100644 --- a/app/cli/internal/action/policy_develop_init.go +++ b/app/cli/internal/action/policy_develop_init.go @@ -16,10 +16,9 @@ package action import ( - "context" "fmt" - policy "github.com/chainloop-dev/chainloop/app/cli/internal/policy/init" + policy "github.com/chainloop-dev/chainloop/app/cli/internal/policydevel" ) type PolicyInitOpts struct { @@ -27,6 +26,7 @@ type PolicyInitOpts struct { Embedded bool Name string Description string + Directory string } type PolicyInit struct { @@ -41,9 +41,9 @@ func NewPolicyInit(opts *PolicyInitOpts, actionOpts *ActionsOpts) (*PolicyInit, }, nil } -func (action *PolicyInit) Run(_ context.Context, dir string) error { +func (action *PolicyInit) Run() error { initOpts := &policy.InitOptions{ - Dir: dir, + Directory: action.opts.Directory, Embedded: action.opts.Embedded, Force: action.opts.Force, Name: action.opts.Name, diff --git a/app/cli/internal/policy/init/init.go b/app/cli/internal/policydevel/init.go similarity index 98% rename from app/cli/internal/policy/init/init.go rename to app/cli/internal/policydevel/init.go index 5c0bb98dc..093054cb7 100644 --- a/app/cli/internal/policy/init/init.go +++ b/app/cli/internal/policydevel/init.go @@ -50,7 +50,7 @@ type Content struct { } type InitOptions struct { - Dir string + Directory string Embedded bool Force bool Name string @@ -73,7 +73,7 @@ func Initialize(opts *InitOptions) error { files[fileNameBase+".rego"] = content.Rego } - return writeFiles(opts.Dir, files, opts.Force) + return writeFiles(opts.Directory, files, opts.Force) } func getPolicyName(name string) string { diff --git a/app/cli/internal/policy/init/templates/example-policy.rego b/app/cli/internal/policydevel/templates/example-policy.rego similarity index 100% rename from app/cli/internal/policy/init/templates/example-policy.rego rename to app/cli/internal/policydevel/templates/example-policy.rego diff --git a/app/cli/internal/policy/init/templates/example-policy.yaml b/app/cli/internal/policydevel/templates/example-policy.yaml similarity index 100% rename from app/cli/internal/policy/init/templates/example-policy.yaml rename to app/cli/internal/policydevel/templates/example-policy.yaml From 3b55df956271572f1e243e7eb4f0045bdd343a4e Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Mon, 14 Jul 2025 14:44:19 +0200 Subject: [PATCH 3/6] feat: add auto generated header to policy example Signed-off-by: Sylwester Piskozub --- app/cli/internal/policydevel/templates/example-policy.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/cli/internal/policydevel/templates/example-policy.yaml b/app/cli/internal/policydevel/templates/example-policy.yaml index 576fae035..d15b576db 100644 --- a/app/cli/internal/policydevel/templates/example-policy.yaml +++ b/app/cli/internal/policydevel/templates/example-policy.yaml @@ -1,3 +1,7 @@ +# Policy generated by Chainloop CLI +# +# For policy examples and reference: +# https://github.com/chainloop-dev/chainloop/tree/main/docs/examples/policies apiVersion: policy.chainloop.dev/v1 kind: ValidationPolicy metadata: From 7af13be75efd116e9f83ea03992d4f152a685358 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Tue, 15 Jul 2025 11:35:06 +0200 Subject: [PATCH 4/6] add tests Signed-off-by: Sylwester Piskozub --- app/cli/documentation/cli-reference.mdx | 8 +- app/cli/internal/policydevel/init_test.go | 257 ++++++++++++++++++++++ 2 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 app/cli/internal/policydevel/init_test.go diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index ef220fd02..be11a0d0d 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -2729,6 +2729,7 @@ Options inherited from parent commands ### chainloop policy develop Tools for policy development +Refer to https://docs.chainloop.dev/guides/custom-policies Options @@ -2797,7 +2798,7 @@ Initialize a new policy by creating template policy files in the specified direc By default, it creates chainloop-policy.yaml and chainloop-policy.rego files. ``` -chainloop policy develop init [directory] [flags] +chainloop policy develop init [flags] ``` Examples @@ -2807,14 +2808,15 @@ Examples Initialize in current directory with separate files chainloop policy develop init -Initialize in specific directory with embedded format -chainloop policy develop init ./policies --embedded +Initialize in specific directory with embedded format and policy name +chainloop policy develop init --directory ./policies --embedded --name mypolicy ``` Options ``` --description string description of the policy +--directory string directory for policy --embedded initialize an embedded policy (single YAML file) -f, --force overwrite existing files -h, --help help for init diff --git a/app/cli/internal/policydevel/init_test.go b/app/cli/internal/policydevel/init_test.go new file mode 100644 index 000000000..3a9d83f69 --- /dev/null +++ b/app/cli/internal/policydevel/init_test.go @@ -0,0 +1,257 @@ +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInitialize(t *testing.T) { + tempDir := t.TempDir() + + t.Run("embedded rego", func(t *testing.T) { + opts := &InitOptions{ + Directory: tempDir, + Embedded: true, + Name: "test-policy", + Description: "test description", + } + + err := Initialize(opts) + require.NoError(t, err) + + policyPath := filepath.Join(tempDir, "test-policy.yaml") + assert.FileExists(t, policyPath) + }) + + t.Run("standalone rego file", func(t *testing.T) { + opts := &InitOptions{ + Directory: tempDir, + Embedded: false, + Name: "standalone-rego", + } + + err := Initialize(opts) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(tempDir, "standalone-rego.yaml")) + assert.FileExists(t, filepath.Join(tempDir, "standalone-rego.rego")) + }) + + t.Run("file exists and no force", func(t *testing.T) { + opts := &InitOptions{ + Directory: tempDir, + Name: "duplicate", + } + + // First time should succeed + err := Initialize(opts) + require.NoError(t, err) + + // Second time should fail + err = Initialize(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + + // With force it should succeed + opts.Force = true + err = Initialize(opts) + require.NoError(t, err) + }) + + t.Run("name and description are properly set", func(t *testing.T) { + customName := "custom-policy-name" + customDesc := "This is a custom policy description" + + opts := &InitOptions{ + Directory: tempDir, + Name: customName, + Description: customDesc, + Embedded: true, + } + + err := Initialize(opts) + require.NoError(t, err) + + policyPath := filepath.Join(tempDir, customName+".yaml") + assert.FileExists(t, policyPath) + + content, err := os.ReadFile(policyPath) + require.NoError(t, err) + + policyContent := string(content) + + assert.Contains(t, policyContent, "name: "+customName) + + assert.Contains(t, policyContent, "description: "+customDesc) + + assert.FileExists(t, filepath.Join(tempDir, customName+".yaml")) + }) +} + +func TestLoadAndProcessTemplates(t *testing.T) { + t.Run("embedded rego", func(t *testing.T) { + opts := &InitOptions{ + Embedded: true, + Name: "embedded-test", + } + + content, err := loadAndProcessTemplates(opts) + require.NoError(t, err) + assert.NotEmpty(t, content.YAML) + assert.Empty(t, content.Rego) // Rego file should be empty for embedded + }) + + t.Run("separate rego file", func(t *testing.T) { + opts := &InitOptions{ + Embedded: false, + Name: "separate-rego-test", + } + + content, err := loadAndProcessTemplates(opts) + require.NoError(t, err) + assert.NotEmpty(t, content.YAML) + assert.NotEmpty(t, content.Rego) + }) +} + +func TestExecuteTemplate(t *testing.T) { + testCases := []struct { + name string + template string + data *TemplateData + expected string + }{ + { + name: "basic interpolation", + template: "Hello {{.Name}}!", + data: &TemplateData{Name: "world"}, + expected: "Hello world!", + }, + { + name: "sanitize function", + template: "{{.Name | sanitize}}", + data: &TemplateData{Name: "My Policy"}, + expected: "my-policy", + }, + { + name: "indent function", + template: "{{indent 2 \"hello\"}}", + expected: " hello", + }, + { + name: "multiple fields interpolation", + template: "Name: {{.Name}}, Desc: {{.Description}}", + data: &TemplateData{Name: "test", Description: "description"}, + expected: "Name: test, Desc: description", + }, + { + name: "trimSpace function", + template: "{{.Name | trimSpace}}", + data: &TemplateData{Name: " spaced "}, + expected: "spaced", + }, + { + name: "combined functions", + template: "{{.Name | trimSpace | sanitize}}", + data: &TemplateData{Name: " My Policy 123 "}, + expected: "my-policy-123", + }, + { + name: "empty template", + template: "", + data: &TemplateData{Name: "test"}, + expected: "", + }, + { + name: "embedded rego flag", + template: "Embedded: {{.Embedded}}", + data: &TemplateData{Embedded: true}, + expected: "Embedded: true", + }, + { + name: "material kind", + template: "Material: {{.MaterialKind}}", + data: &TemplateData{MaterialKind: "SBOM_CYCLONEDX_JSON"}, + expected: "Material: SBOM_CYCLONEDX_JSON", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := executeTemplate(tc.template, tc.data) + require.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } + + errorCases := []struct { + name string + template string + data *TemplateData + errMsg string + }{ + { + name: "invalid template syntax", + template: "{{.Name", + data: &TemplateData{Name: "test"}, + errMsg: "template parsing error", + }, + { + name: "missing field", + template: "{{.MissingField}}", + data: &TemplateData{Name: "test"}, + errMsg: "template execution error", + }, + { + name: "invalid function", + template: "{{.Name | invalidFunc}}", + data: &TemplateData{Name: "test"}, + errMsg: "template parsing error", + }, + } + + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + _, err := executeTemplate(tc.template, tc.data) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errMsg) + }) + } +} + +func TestSanitizeName(t *testing.T) { + testCases := []struct { + input string + expected string + }{ + {"My Policy", "my-policy"}, + {" Trim Spaces ", "trim-spaces"}, + {"UPPER CASE", "upper-case"}, + {"Special!@#Chars", "special!@#chars"}, + {"", ""}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.expected, sanitizeName(tc.input)) + }) + } +} From b1548fc7e467603d8bd4226fd965671f1f058028 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Tue, 15 Jul 2025 11:52:55 +0200 Subject: [PATCH 5/6] fix lint error Signed-off-by: Sylwester Piskozub --- app/cli/cmd/policy_develop_init.go | 2 +- app/cli/internal/policydevel/init.go | 1 + app/cli/internal/policydevel/init_test.go | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/cli/cmd/policy_develop_init.go b/app/cli/cmd/policy_develop_init.go index 6d65529b7..03d6e826f 100644 --- a/app/cli/cmd/policy_develop_init.go +++ b/app/cli/cmd/policy_develop_init.go @@ -42,7 +42,7 @@ By default, it creates chainloop-policy.yaml and chainloop-policy.rego files.`, # Initialize in specific directory with embedded format and policy name chainloop policy develop init --directory ./policies --embedded --name mypolicy`, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(_ *cobra.Command, _ []string) error { if directory == "" { directory = "." } diff --git a/app/cli/internal/policydevel/init.go b/app/cli/internal/policydevel/init.go index 093054cb7..de8053bd5 100644 --- a/app/cli/internal/policydevel/init.go +++ b/app/cli/internal/policydevel/init.go @@ -1,3 +1,4 @@ +// // Copyright 2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/app/cli/internal/policydevel/init_test.go b/app/cli/internal/policydevel/init_test.go index 3a9d83f69..77a0bd599 100644 --- a/app/cli/internal/policydevel/init_test.go +++ b/app/cli/internal/policydevel/init_test.go @@ -1,3 +1,4 @@ +// // Copyright 2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); From eb481194388bda443ddbae0086d1d9534b826dff Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Wed, 16 Jul 2025 14:10:24 +0200 Subject: [PATCH 6/6] fix template fields; change package name Signed-off-by: Sylwester Piskozub --- app/cli/internal/action/policy_develop_init.go | 6 +++--- app/cli/internal/policydevel/init.go | 2 +- app/cli/internal/policydevel/init_test.go | 2 +- app/cli/internal/policydevel/templates/example-policy.yaml | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/cli/internal/action/policy_develop_init.go b/app/cli/internal/action/policy_develop_init.go index 2131abca6..735d3efc8 100644 --- a/app/cli/internal/action/policy_develop_init.go +++ b/app/cli/internal/action/policy_develop_init.go @@ -18,7 +18,7 @@ package action import ( "fmt" - policy "github.com/chainloop-dev/chainloop/app/cli/internal/policydevel" + "github.com/chainloop-dev/chainloop/app/cli/internal/policydevel" ) type PolicyInitOpts struct { @@ -42,7 +42,7 @@ func NewPolicyInit(opts *PolicyInitOpts, actionOpts *ActionsOpts) (*PolicyInit, } func (action *PolicyInit) Run() error { - initOpts := &policy.InitOptions{ + initOpts := &policydevel.InitOptions{ Directory: action.opts.Directory, Embedded: action.opts.Embedded, Force: action.opts.Force, @@ -50,7 +50,7 @@ func (action *PolicyInit) Run() error { Description: action.opts.Description, } - if err := policy.Initialize(initOpts); err != nil { + if err := policydevel.Initialize(initOpts); err != nil { return fmt.Errorf("initializing policy: %w", err) } diff --git a/app/cli/internal/policydevel/init.go b/app/cli/internal/policydevel/init.go index de8053bd5..c8a50cf91 100644 --- a/app/cli/internal/policydevel/init.go +++ b/app/cli/internal/policydevel/init.go @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package policy +package policydevel import ( "bytes" diff --git a/app/cli/internal/policydevel/init_test.go b/app/cli/internal/policydevel/init_test.go index 77a0bd599..ddd537383 100644 --- a/app/cli/internal/policydevel/init_test.go +++ b/app/cli/internal/policydevel/init_test.go @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package policy +package policydevel import ( "os" diff --git a/app/cli/internal/policydevel/templates/example-policy.yaml b/app/cli/internal/policydevel/templates/example-policy.yaml index d15b576db..76fea0999 100644 --- a/app/cli/internal/policydevel/templates/example-policy.yaml +++ b/app/cli/internal/policydevel/templates/example-policy.yaml @@ -2,8 +2,8 @@ # # For policy examples and reference: # https://github.com/chainloop-dev/chainloop/tree/main/docs/examples/policies -apiVersion: policy.chainloop.dev/v1 -kind: ValidationPolicy +apiVersion: workflowcontract.chainloop.dev/v1 +kind: Policy metadata: name: {{.Name | sanitize}} description: {{.Description | trimSpace}} @@ -15,7 +15,7 @@ spec: {{if .Embedded -}} # Embedded Rego policy # See docs: https://docs.chainloop.dev/guides/custom-policies#embedded-vs-external - rego: | + embedded: | {{.RegoContent | indent 8}} {{else -}} # Path to external Rego policy file