Skip to content

Commit 6e91c81

Browse files
authored
Merge pull request #2945 from joejstuart/compare-ecp-specs
feat(equivalence): Compare EnterpriseContractPolicy specs
2 parents c8f1a39 + 8198186 commit 6e91c81

File tree

10 files changed

+2493
-4
lines changed

10 files changed

+2493
-4
lines changed

cmd/compare/README.md

Lines changed: 434 additions & 0 deletions
Large diffs are not rendered by default.

cmd/compare/compare.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright The Conforma Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
package compare
18+
19+
import (
20+
"encoding/json"
21+
"fmt"
22+
"os"
23+
"time"
24+
25+
ecc "github.com/conforma/crds/api/v1alpha1"
26+
"github.com/spf13/cobra"
27+
"sigs.k8s.io/yaml"
28+
29+
"github.com/conforma/cli/internal/policy/equivalence"
30+
)
31+
32+
var (
33+
effectiveTime string
34+
imageDigest string
35+
imageRef string
36+
imageURL string
37+
outputFormat string
38+
)
39+
40+
var CompareCmd *cobra.Command
41+
42+
func init() {
43+
CompareCmd = NewCompareCmd()
44+
}
45+
46+
func NewCompareCmd() *cobra.Command {
47+
compareCmd := &cobra.Command{
48+
Use: "compare <policy1> <policy2>",
49+
Short: "Compare two Conforma Policy specs for equivalence",
50+
Long: `Compare two Conforma Policy specs to determine if they would
51+
produce the same evaluation result for a given image at a specific time.
52+
53+
The comparison is based on:
54+
- Policy and data source URIs (treated as sets)
55+
- RuleData content (canonicalized JSON comparison)
56+
- Include/exclude matchers (normalized and deduplicated)
57+
- Active volatile configuration (filtered by effective time and image matching)
58+
- Global configuration merging
59+
60+
Examples:
61+
# Compare two policy files
62+
ec compare policy1.yaml policy2.yaml
63+
64+
# Compare with specific effective time
65+
ec compare policy1.yaml policy2.yaml --effective-time "2024-01-15T12:00:00Z"
66+
67+
# Compare with image information for volatile config matching
68+
ec compare policy1.yaml policy2.yaml --image-digest "sha256:abc123" --image-ref "registry.redhat.io/ubi8/ubi:latest"
69+
70+
# Compare with JSON output
71+
ec compare policy1.yaml policy2.yaml --output json`,
72+
Args: cobra.ExactArgs(2),
73+
RunE: runCompare,
74+
}
75+
76+
compareCmd.Flags().StringVar(&effectiveTime, "effective-time", "now", "Effective time for policy evaluation (RFC3339 format, 'now')")
77+
compareCmd.Flags().StringVar(&imageDigest, "image-digest", "", "Image digest for volatile config matching")
78+
compareCmd.Flags().StringVar(&imageRef, "image-ref", "", "Image reference for volatile config matching")
79+
compareCmd.Flags().StringVar(&imageURL, "image-url", "", "Image URL for volatile config matching")
80+
compareCmd.Flags().StringVar(&outputFormat, "output", "text", "Output format (text, json)")
81+
82+
return compareCmd
83+
}
84+
85+
func runCompare(cmd *cobra.Command, args []string) error {
86+
87+
// Parse effective time
88+
var effectiveTimeValue time.Time
89+
switch effectiveTime {
90+
case "now":
91+
effectiveTimeValue = time.Now().UTC()
92+
case "attestation":
93+
// For now, use current time as default for attestation time
94+
effectiveTimeValue = time.Now().UTC()
95+
default:
96+
var err error
97+
effectiveTimeValue, err = time.Parse(time.RFC3339, effectiveTime)
98+
if err != nil {
99+
return fmt.Errorf("invalid effective time format: %w", err)
100+
}
101+
}
102+
103+
// Create image info if provided
104+
var imageInfo *equivalence.ImageInfo
105+
if imageDigest != "" || imageRef != "" || imageURL != "" {
106+
imageInfo = &equivalence.ImageInfo{
107+
Digest: imageDigest,
108+
Ref: imageRef,
109+
URL: imageURL,
110+
}
111+
}
112+
113+
// Load first policy
114+
spec1, err := loadPolicySpec(args[0])
115+
if err != nil {
116+
return fmt.Errorf("failed to load first policy: %w", err)
117+
}
118+
119+
// Load second policy
120+
spec2, err := loadPolicySpec(args[1])
121+
if err != nil {
122+
return fmt.Errorf("failed to load second policy: %w", err)
123+
}
124+
125+
// Create equivalence checker
126+
checker := equivalence.NewEquivalenceChecker(effectiveTimeValue, imageInfo)
127+
128+
// Compare policies
129+
equivalent, err := checker.AreEquivalent(spec1, spec2)
130+
if err != nil {
131+
return fmt.Errorf("failed to compare policies: %w", err)
132+
}
133+
134+
// Output result
135+
if outputFormat == "json" {
136+
result := map[string]interface{}{
137+
"equivalent": equivalent,
138+
"effective_time": effectiveTimeValue.Format(time.RFC3339),
139+
"policy1": args[0],
140+
"policy2": args[1],
141+
}
142+
if imageInfo != nil {
143+
result["image_info"] = imageInfo
144+
}
145+
encoder := json.NewEncoder(os.Stdout)
146+
encoder.SetIndent("", " ")
147+
return encoder.Encode(result)
148+
}
149+
150+
// Text output
151+
if equivalent {
152+
fmt.Println("✅ Policies are equivalent")
153+
} else {
154+
fmt.Println("❌ Policies are not equivalent")
155+
}
156+
157+
fmt.Printf("Effective time: %s\n", effectiveTimeValue.Format(time.RFC3339))
158+
if imageInfo != nil {
159+
fmt.Printf("Image digest: %s\n", imageInfo.Digest)
160+
fmt.Printf("Image ref: %s\n", imageInfo.Ref)
161+
fmt.Printf("Image URL: %s\n", imageInfo.URL)
162+
}
163+
164+
return nil
165+
}
166+
167+
func loadPolicySpec(policyRef string) (ecc.EnterpriseContractPolicySpec, error) {
168+
content, err := os.ReadFile(policyRef)
169+
if err != nil {
170+
return ecc.EnterpriseContractPolicySpec{}, fmt.Errorf("failed to read policy file %q: %w", policyRef, err)
171+
}
172+
173+
var ecp ecc.EnterpriseContractPolicy
174+
if err := yaml.Unmarshal(content, &ecp); err != nil {
175+
// If parsing as EnterpriseContractPolicy fails, try as EnterpriseContractPolicySpec
176+
var spec ecc.EnterpriseContractPolicySpec
177+
if err := yaml.Unmarshal(content, &spec); err != nil {
178+
return ecc.EnterpriseContractPolicySpec{}, fmt.Errorf("unable to parse EnterpriseContractPolicySpec: %w", err)
179+
}
180+
return spec, nil
181+
}
182+
183+
// Check if this is actually a valid CRD (has required fields)
184+
if ecp.APIVersion == "" || ecp.Kind == "" {
185+
// This is not a valid CRD, try parsing as EnterpriseContractPolicySpec
186+
var spec ecc.EnterpriseContractPolicySpec
187+
if err := yaml.Unmarshal(content, &spec); err != nil {
188+
return ecc.EnterpriseContractPolicySpec{}, fmt.Errorf("unable to parse EnterpriseContractPolicySpec: %w", err)
189+
}
190+
return spec, nil
191+
}
192+
193+
return ecp.Spec, nil
194+
}

cmd/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
log "github.com/sirupsen/logrus"
2323
"github.com/spf13/cobra"
2424

25+
"github.com/conforma/cli/cmd/compare"
2526
"github.com/conforma/cli/cmd/fetch"
2627
"github.com/conforma/cli/cmd/initialize"
2728
"github.com/conforma/cli/cmd/inspect"
@@ -56,6 +57,7 @@ func init() {
5657
}
5758

5859
func AddCommandsTo(cmd *cobra.Command) {
60+
cmd.AddCommand(compare.CompareCmd)
5961
cmd.AddCommand(fetch.FetchCmd)
6062
cmd.AddCommand(initialize.InitCmd)
6163
cmd.AddCommand(inspect.InspectCmd)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
= ec compare
2+
3+
Compare two Conforma Policy specs for equivalence
4+
5+
== Synopsis
6+
7+
Compare two Conforma Policy specs to determine if they would
8+
produce the same evaluation result for a given image at a specific time.
9+
10+
The comparison is based on:
11+
- Policy and data source URIs (treated as sets)
12+
- RuleData content (canonicalized JSON comparison)
13+
- Include/exclude matchers (normalized and deduplicated)
14+
- Active volatile configuration (filtered by effective time and image matching)
15+
- Global configuration merging
16+
17+
Examples:
18+
# Compare two policy files
19+
ec compare policy1.yaml policy2.yaml
20+
21+
# Compare with specific effective time
22+
ec compare policy1.yaml policy2.yaml --effective-time "2024-01-15T12:00:00Z"
23+
24+
# Compare with image information for volatile config matching
25+
ec compare policy1.yaml policy2.yaml --image-digest "sha256:abc123" --image-ref "registry.redhat.io/ubi8/ubi:latest"
26+
27+
# Compare with JSON output
28+
ec compare policy1.yaml policy2.yaml --output json
29+
[source,shell]
30+
----
31+
ec compare <policy1> <policy2> [flags]
32+
----
33+
== Options
34+
35+
--effective-time:: Effective time for policy evaluation (RFC3339 format, 'now') (Default: now)
36+
-h, --help:: help for compare (Default: false)
37+
--image-digest:: Image digest for volatile config matching
38+
--image-ref:: Image reference for volatile config matching
39+
--image-url:: Image URL for volatile config matching
40+
--output:: Output format (text, json) (Default: text)
41+
42+
== Options inherited from parent commands
43+
44+
--debug:: same as verbose but also show function names and line numbers (Default: false)
45+
--kubeconfig:: path to the Kubernetes config file to use
46+
--logfile:: file to write the logging output. If not specified logging output will be written to stderr
47+
--quiet:: less verbose output (Default: false)
48+
--retry-duration:: base duration for exponential backoff calculation (Default: 1s)
49+
--retry-factor:: exponential backoff multiplier (Default: 2)
50+
--retry-jitter:: randomness factor for backoff calculation (0.0-1.0) (Default: 0.1)
51+
--retry-max-retry:: maximum number of retry attempts (Default: 3)
52+
--retry-max-wait:: maximum wait time between retries (Default: 3s)
53+
--timeout:: max overall execution duration (Default: 5m0s)
54+
--trace:: enable trace logging, set one or more comma separated values: none,all,perf,cpu,mem,opa,log (Default: none)
55+
--verbose:: more verbose output (Default: false)
56+
57+
== See also
58+
59+
* xref:ec.adoc[ec - Conforma CLI]

docs/modules/ROOT/partials/cli_nav.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
* xref:reference.adoc[Command Reference]
22
** xref:ec.adoc[ec]
3+
** xref:ec_compare.adoc[ec compare]
34
** xref:ec_fetch.adoc[ec fetch]
45
** xref:ec_fetch_policy.adoc[ec fetch policy]
56
** xref:ec_init.adoc[ec init]

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ require (
77
github.com/CycloneDX/cyclonedx-go v0.9.2
88
github.com/MakeNowJust/heredoc v1.0.0
99
github.com/Maldris/go-billy-afero v0.0.0-20200815120323-e9d3de59c99a
10+
github.com/conforma/crds/api v0.1.0
1011
github.com/conforma/go-gather v1.0.2
1112
github.com/docker/docker v28.2.2+incompatible
12-
github.com/conforma/crds/api v0.1.0
1313
github.com/evanphx/json-patch v5.9.0+incompatible
1414
github.com/gkampitakis/go-snaps v0.5.7
1515
github.com/go-git/go-git/v5 v5.13.2
@@ -62,6 +62,8 @@ require (
6262
// use forked version until we can get the fixes merged see https://github.com/conforma/go-containerregistry/blob/main/hack/ec-patches.sh for a list of patches we carry
6363
replace github.com/google/go-containerregistry => github.com/conforma/go-containerregistry v0.20.7-0.20250703195040-6f40a3734728
6464

65+
require github.com/go-openapi/runtime v0.28.0
66+
6567
require (
6668
cel.dev/expr v0.20.0 // indirect
6769
cloud.google.com/go v0.116.0 // indirect
@@ -202,7 +204,6 @@ require (
202204
github.com/go-openapi/jsonpointer v0.21.0 // indirect
203205
github.com/go-openapi/jsonreference v0.21.0 // indirect
204206
github.com/go-openapi/loads v0.22.0 // indirect
205-
github.com/go-openapi/runtime v0.28.0 // indirect
206207
github.com/go-openapi/spec v0.21.0 // indirect
207208
github.com/go-openapi/swag v0.23.0 // indirect
208209
github.com/go-openapi/validate v0.24.0 // indirect

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -438,8 +438,6 @@ github.com/emicklei/proto v1.14.0 h1:WYxC0OrBuuC+FUCTZvb8+fzEHdZMwLEF+OnVfZA3LXU
438438
github.com/emicklei/proto v1.14.0/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
439439
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
440440
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
441-
github.com/enterprise-contract/enterprise-contract-controller/api v0.1.112 h1:4/PBvqANhDiJgpzOZG/zCj7Gl6jtsaMFRwCiFF2KNOg=
442-
github.com/enterprise-contract/enterprise-contract-controller/api v0.1.112/go.mod h1:YfIsqAmIc3ncPfxOw6LEsngWdeAVSO+V7svjoNpxyKs=
443441
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
444442
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
445443
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright The Conforma Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
package equivalence
18+
19+
import (
20+
"bytes"
21+
"encoding/json"
22+
"sort"
23+
)
24+
25+
// marshalCanonical creates deterministic JSON by sorting map keys at every level
26+
func marshalCanonical(v any) ([]byte, error) {
27+
var buf bytes.Buffer
28+
if err := encodeCanonical(&buf, v); err != nil {
29+
return nil, err
30+
}
31+
return buf.Bytes(), nil
32+
}
33+
34+
// encodeCanonical recursively encodes JSON with sorted keys for deterministic output
35+
func encodeCanonical(buf *bytes.Buffer, v any) error {
36+
switch x := v.(type) {
37+
case map[string]any:
38+
// Sort keys to ensure deterministic output
39+
keys := make([]string, 0, len(x))
40+
for k := range x {
41+
keys = append(keys, k)
42+
}
43+
sort.Strings(keys)
44+
45+
buf.WriteByte('{')
46+
for i, k := range keys {
47+
if i > 0 {
48+
buf.WriteByte(',')
49+
}
50+
// Encode key (always a string, so use standard JSON encoding)
51+
keyBytes, err := json.Marshal(k)
52+
if err != nil {
53+
return err
54+
}
55+
buf.Write(keyBytes)
56+
buf.WriteByte(':')
57+
// Recursively encode value
58+
if err := encodeCanonical(buf, x[k]); err != nil {
59+
return err
60+
}
61+
}
62+
buf.WriteByte('}')
63+
case []any:
64+
buf.WriteByte('[')
65+
for i, item := range x {
66+
if i > 0 {
67+
buf.WriteByte(',')
68+
}
69+
if err := encodeCanonical(buf, item); err != nil {
70+
return err
71+
}
72+
}
73+
buf.WriteByte(']')
74+
default:
75+
// For scalars (numbers, strings, bool, null), use standard JSON encoding
76+
b, err := json.Marshal(x)
77+
if err != nil {
78+
return err
79+
}
80+
buf.Write(b)
81+
}
82+
return nil
83+
}

0 commit comments

Comments
 (0)