Skip to content

Commit a8da081

Browse files
authored
Merge pull request #31 from carabiner-dev/update
Add check-update command
2 parents ec53985 + 2da36f6 commit a8da081

4 files changed

Lines changed: 226 additions & 4 deletions

File tree

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ require (
77
github.com/carabiner-dev/ampel v1.1.4
88
github.com/carabiner-dev/collector v0.3.4-0.20260405002205-9441e61c839f
99
github.com/carabiner-dev/command v0.3.1-0.20260313054653-5c2e5699363e
10-
github.com/carabiner-dev/policy v0.4.5
10+
github.com/carabiner-dev/policy v0.4.6-0.20260414201106-ed3793f37c4e
1111
github.com/carabiner-dev/signer v0.4.3
1212
github.com/fatih/color v1.19.0
1313
github.com/in-toto/attestation v1.2.0
@@ -20,6 +20,7 @@ require (
2020
require (
2121
github.com/charmbracelet/glamour v1.0.0
2222
github.com/yuin/goldmark v1.8.2
23+
golang.org/x/term v0.41.0
2324
gopkg.in/yaml.v3 v3.0.1
2425
)
2526

@@ -182,7 +183,6 @@ require (
182183
golang.org/x/oauth2 v0.36.0 // indirect
183184
golang.org/x/sync v0.20.0 // indirect
184185
golang.org/x/sys v0.42.0 // indirect
185-
golang.org/x/term v0.41.0 // indirect
186186
golang.org/x/text v0.35.0 // indirect
187187
google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c // indirect
188188
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ github.com/carabiner-dev/openeox v0.0.0-20260302211234-88fe8a305401 h1:fexoMxtBC
126126
github.com/carabiner-dev/openeox v0.0.0-20260302211234-88fe8a305401/go.mod h1:YDuJw950hcWHl/WWJTpmyAMfRRY3rNXgy4NxnTtRTIY=
127127
github.com/carabiner-dev/osv v0.0.0-20250124012120-b8ce4531cd92 h1:BJ9+OCNezZGkU8SrGC3oB7Tj+J0JsonwfZztcgUav6c=
128128
github.com/carabiner-dev/osv v0.0.0-20250124012120-b8ce4531cd92/go.mod h1:o7jXwi/fFZ9mQlvVlog0kcvyEkwQT3eWmVQmrorBGpE=
129-
github.com/carabiner-dev/policy v0.4.5 h1:N4H9HHCJuWxS3YYhqZC8uXeMZrKaMD0M+DPem7lBZiE=
130-
github.com/carabiner-dev/policy v0.4.5/go.mod h1:BTWt59Ec0T3LoQwlWvFC+lhRQheHhSeWmlr7T9G6vX8=
129+
github.com/carabiner-dev/policy v0.4.6-0.20260414201106-ed3793f37c4e h1:h1lya0Ptpzh3i5TGgk2fFfCDukhURm8x/nm19eFnYNQ=
130+
github.com/carabiner-dev/policy v0.4.6-0.20260414201106-ed3793f37c4e/go.mod h1:k9RPlcH8Pi0mJQAkiLm0bFhvRLfcYHmkEK0gDJi7/CU=
131131
github.com/carabiner-dev/predicates v0.1.0 h1:t6tQF9gFdr6TIccWtuNk3kFasx8eu88INFVGkCUnjL4=
132132
github.com/carabiner-dev/predicates v0.1.0/go.mod h1:jL6EAD+LiI6GW/rOdRYAJF4HaA88/V2Q4n7yUGNQ7XM=
133133
github.com/carabiner-dev/sbomfs v0.1.0 h1:gEsmn85hod7JTLs2dDr5C1x4Af7FUEhI0lbTurNaEZs=

internal/cmd/check_update.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// SPDX-FileCopyrightText: Copyright 2026 Carabiner Systems, Inc
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cmd
5+
6+
import (
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"os"
11+
"sort"
12+
"strings"
13+
14+
"github.com/carabiner-dev/policy"
15+
"github.com/spf13/cobra"
16+
"golang.org/x/term"
17+
"google.golang.org/protobuf/encoding/protojson"
18+
)
19+
20+
const (
21+
outputFormatTable = "table"
22+
outputFormatJSON = "json"
23+
)
24+
25+
const (
26+
defaultTerminalWidth = 100
27+
shortDigestLen = 9
28+
)
29+
30+
func addCheckUpdate(parentCmd *cobra.Command) {
31+
var format string
32+
cmd := &cobra.Command{
33+
Short: "check policy references for available updates",
34+
Use: "check-update [flags] <location> [<location>...]",
35+
Example: fmt.Sprintf(" %s check-update ./policies", appname),
36+
SilenceUsage: false,
37+
SilenceErrors: true,
38+
Long: `Check one or more policy source locations for external references
39+
that have updates available.
40+
41+
Each location may be a policy file, a directory containing policies, or a
42+
VCS locator (e.g. git+https://github.com/org/repo@ref#path). Directory
43+
locations are walked and every policy, policy set, or policy group file
44+
discovered is inspected for external references.
45+
46+
For every reference, the upstream repository is queried for its latest
47+
commit. If the referenced policy's content has changed, an entry is
48+
reported showing the old and new commits and digests.`,
49+
PersistentPreRunE: initLogging,
50+
RunE: func(cmd *cobra.Command, args []string) error {
51+
if len(args) == 0 {
52+
return fmt.Errorf("at least one location is required")
53+
}
54+
cmd.SilenceUsage = true
55+
56+
updates, err := policy.NewUpdater().CheckUpdates(args...)
57+
if err != nil {
58+
return err
59+
}
60+
61+
switch format {
62+
case outputFormatJSON:
63+
return writeUpdatesJSON(os.Stdout, updates)
64+
case outputFormatTable, "":
65+
if len(updates) == 0 {
66+
fmt.Fprintln(os.Stderr, "no policy references have updates available")
67+
return nil
68+
}
69+
printUpdatesTable(os.Stdout, updates)
70+
return nil
71+
default:
72+
return fmt.Errorf("unknown format %q (use table or json)", format)
73+
}
74+
},
75+
}
76+
cmd.Flags().StringVarP(&format, "format", "f", outputFormatTable, "output format: table, json")
77+
parentCmd.AddCommand(cmd)
78+
}
79+
80+
// updatePlan is the JSON shape emitted by --format=json. It is intended
81+
// to be stable enough to be re-ingested as a plan for a future apply
82+
// command (similar to terraform plan output).
83+
type updatePlan struct {
84+
Version int `json:"version"`
85+
Files map[string][]planEntry `json:"files"`
86+
}
87+
88+
type planEntry struct {
89+
Old json.RawMessage `json:"old"`
90+
New json.RawMessage `json:"new"`
91+
}
92+
93+
func writeUpdatesJSON(out io.Writer, updates map[string][]*policy.RefUpdate) error {
94+
plan := updatePlan{
95+
Version: 1,
96+
Files: map[string][]planEntry{},
97+
}
98+
marshaler := protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: false}
99+
for file, refs := range updates {
100+
entries := make([]planEntry, 0, len(refs))
101+
for _, r := range refs {
102+
oldRaw, err := marshaler.Marshal(r.Old)
103+
if err != nil {
104+
return fmt.Errorf("marshaling old ref for %s: %w", file, err)
105+
}
106+
newRaw, err := marshaler.Marshal(r.New)
107+
if err != nil {
108+
return fmt.Errorf("marshaling new ref for %s: %w", file, err)
109+
}
110+
entries = append(entries, planEntry{Old: oldRaw, New: newRaw})
111+
}
112+
plan.Files[file] = entries
113+
}
114+
115+
enc := json.NewEncoder(out)
116+
enc.SetIndent("", " ")
117+
return enc.Encode(plan)
118+
}
119+
120+
func printUpdatesTable(out io.Writer, updates map[string][]*policy.RefUpdate) {
121+
files := make([]string, 0, len(updates))
122+
for f := range updates {
123+
files = append(files, f)
124+
}
125+
sort.Strings(files)
126+
127+
width := terminalWidth(out)
128+
// Reserve space for: "| > " (4), " | " between uri and old (3),
129+
// " | " between old and new (3), " |" trailing (2). Plus one space
130+
// of padding on each side of each cell (already part of those 3/2
131+
// separators), so content budget = width - 12.
132+
const chromeWidth = 12
133+
const minURIWidth = 20
134+
uriWidth := width - chromeWidth - 2*shortDigestLen
135+
if uriWidth < minURIWidth {
136+
uriWidth = minURIWidth
137+
}
138+
139+
rowWidth := chromeWidth + uriWidth + 2*shortDigestLen
140+
border := strings.Repeat("-", rowWidth)
141+
142+
p := func(format string, args ...any) {
143+
fmt.Fprintf(out, format, args...) //nolint:errcheck // stdout write errors are not actionable here
144+
}
145+
for i, f := range files {
146+
if i > 0 {
147+
p("\n")
148+
}
149+
p("%s\n", border)
150+
p("| %s |\n", padRight(f, rowWidth-4))
151+
p("%s\n", border)
152+
for _, u := range updates[f] {
153+
uri := bareURI(u.Old.GetLocation().GetUri())
154+
oldDigest := short(u.Old.GetLocation().GetDigest()["sha256"])
155+
newDigest := short(u.New.GetLocation().GetDigest()["sha256"])
156+
p("| > %s | %s | %s |\n",
157+
padRight(trimLeft(uri, uriWidth), uriWidth),
158+
padRight(oldDigest, shortDigestLen),
159+
padRight(newDigest, shortDigestLen),
160+
)
161+
}
162+
p("%s\n", border)
163+
}
164+
}
165+
166+
// bareURI strips the pinned commit from a VCS locator (the "@<rev>" before
167+
// the subpath fragment), so the URI that is displayed identifies the
168+
// policy location rather than the old revision.
169+
func bareURI(uri string) string {
170+
hashIdx := strings.Index(uri, "#")
171+
prefix, suffix := uri, ""
172+
if hashIdx >= 0 {
173+
prefix = uri[:hashIdx]
174+
suffix = uri[hashIdx:]
175+
}
176+
if at := strings.LastIndex(prefix, "@"); at >= 0 {
177+
// Only trim when the '@' is part of the revision (i.e. after "://").
178+
if scheme := strings.Index(prefix, "://"); scheme < 0 || at > scheme+3 {
179+
prefix = prefix[:at]
180+
}
181+
}
182+
return prefix + suffix
183+
}
184+
185+
func short(s string) string {
186+
if len(s) > shortDigestLen {
187+
return s[:shortDigestLen]
188+
}
189+
return s
190+
}
191+
192+
// trimLeft shortens s from the left with a leading ellipsis so it fits in
193+
// `width` runes.
194+
func trimLeft(s string, width int) string {
195+
if len(s) <= width {
196+
return s
197+
}
198+
if width <= 3 {
199+
return s[len(s)-width:]
200+
}
201+
return "..." + s[len(s)-(width-3):]
202+
}
203+
204+
func padRight(s string, width int) string {
205+
if len(s) >= width {
206+
return s
207+
}
208+
return s + strings.Repeat(" ", width-len(s))
209+
}
210+
211+
func terminalWidth(out io.Writer) int {
212+
f, ok := out.(*os.File)
213+
if !ok {
214+
return defaultTerminalWidth
215+
}
216+
w, _, err := term.GetSize(int(f.Fd())) //nolint:gosec // fd values fit in int on all supported platforms
217+
if err != nil || w <= 0 {
218+
return defaultTerminalWidth
219+
}
220+
return w
221+
}

internal/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func New() *cobra.Command {
4040
addSign(rootCmd)
4141
addKeys(rootCmd)
4242
addUpdate(rootCmd)
43+
addCheckUpdate(rootCmd)
4344
addVerify(rootCmd)
4445
addTest(rootCmd)
4546
addDoc(rootCmd)

0 commit comments

Comments
 (0)