Skip to content

Commit abcac08

Browse files
authored
Merge pull request #32 from carabiner-dev/update
Add update subcommand
2 parents a8da081 + e225952 commit abcac08

3 files changed

Lines changed: 166 additions & 60 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
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.6-0.20260414201106-ed3793f37c4e
10+
github.com/carabiner-dev/policy v0.4.6-0.20260414211325-bc1592462719
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

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.6-0.20260414201106-ed3793f37c4e h1:h1lya0Ptpzh3i5TGgk2fFfCDukhURm8x/nm19eFnYNQ=
130-
github.com/carabiner-dev/policy v0.4.6-0.20260414201106-ed3793f37c4e/go.mod h1:k9RPlcH8Pi0mJQAkiLm0bFhvRLfcYHmkEK0gDJi7/CU=
129+
github.com/carabiner-dev/policy v0.4.6-0.20260414211325-bc1592462719 h1:KisYl6FjxsUt2cLVcXEeHDlv8ZwKQ6LVcGucX4cwmYQ=
130+
github.com/carabiner-dev/policy v0.4.6-0.20260414211325-bc1592462719/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/update.go

Lines changed: 163 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,193 @@
1-
// SPDX-FileCopyrightText: Copyright 2025 Carabiner Systems, Inc
1+
// SPDX-FileCopyrightText: Copyright 2026 Carabiner Systems, Inc
22
// SPDX-License-Identifier: Apache-2.0
33

44
package cmd
55

66
import (
7-
"errors"
7+
"bufio"
8+
"encoding/json"
89
"fmt"
9-
"io"
1010
"os"
11+
"sort"
12+
"strings"
1113

1214
"github.com/carabiner-dev/policy"
15+
api "github.com/carabiner-dev/policy/api/v1"
1316
"github.com/spf13/cobra"
1417
"google.golang.org/protobuf/encoding/protojson"
1518
)
1619

17-
type updateOptions struct {
18-
policyFile string
19-
}
20+
func addUpdate(parentCmd *cobra.Command) {
21+
var (
22+
fromJSON string
23+
force bool
24+
)
25+
updateCmd := &cobra.Command{
26+
Short: "update policy references to their latest versions",
27+
Use: "update [flags] <location> [<location>...]",
28+
Long: `Resolve each location as a policy file or a directory of policies,
29+
look up updates for every external reference, and patch the matching
30+
policy source files in place.
2031
21-
// Validates the options in context with arguments
22-
func (co *updateOptions) Validate() error {
23-
errs := []error{}
24-
if co.policyFile == "" {
25-
errs = append(errs, errors.New("no policy file specified"))
26-
}
27-
return errors.Join(errs...)
28-
}
32+
Only filesystem locations are supported: remote (VCS locator) locations
33+
will be skipped.
2934
30-
// AddFlags adds the subcommands flags
31-
func (co *updateOptions) AddFlags(cmd *cobra.Command) {
32-
cmd.PersistentFlags().StringVarP(
33-
&co.policyFile, "policy", "p", "", "path to policy file",
34-
)
35-
}
35+
By default, the available updates are displayed as a table and the user
36+
is asked to confirm before anything is written. Use --force to skip the
37+
prompt and apply updates directly.
3638
37-
func addUpdate(parentCmd *cobra.Command) {
38-
opts := &updateOptions{}
39-
updateCmd := &cobra.Command{
40-
Short: "updates a policy or policyset from sources",
41-
Use: "update",
42-
Example: fmt.Sprintf(`%s update policy.json`, appname),
39+
When --from-json is provided, updates are read from the given plan file
40+
instead of being computed from scratch. The plan file is the JSON
41+
document produced by 'policyctl check-update --format=json'; no remote
42+
calls are made in this mode.
43+
44+
The patch rewrites old URIs, download locations, and digest values in
45+
place so that the resulting diff against the original file is limited to
46+
the strings that actually changed.`,
47+
Example: fmt.Sprintf(` %s update ./policies`, appname),
4348
SilenceUsage: false,
4449
SilenceErrors: true,
4550
PersistentPreRunE: initLogging,
46-
PreRunE: func(_ *cobra.Command, args []string) error {
47-
if len(args) > 0 {
48-
if opts.policyFile == "" {
49-
opts.policyFile = args[0]
50-
}
51-
if args[0] != opts.policyFile {
52-
return fmt.Errorf("policy path speficied twice (as argument and flag)")
53-
}
54-
}
55-
return nil
56-
},
57-
RunE: func(cmd *cobra.Command, args []string) (err error) {
58-
// Validate the options
59-
if err := opts.Validate(); err != nil {
60-
return err
61-
}
51+
RunE: func(cmd *cobra.Command, args []string) error {
6252
cmd.SilenceUsage = true
53+
u := policy.NewUpdater()
6354

64-
set, _, _, err := policy.NewParser().Open(opts.policyFile)
65-
if err != nil {
66-
return err
55+
var (
56+
applied map[string][]*policy.RefUpdate
57+
err error
58+
)
59+
switch {
60+
case fromJSON != "":
61+
if len(args) > 0 {
62+
return fmt.Errorf("--from-json cannot be combined with positional locations")
63+
}
64+
applied, err = runFromJSON(u, fromJSON, force)
65+
default:
66+
if len(args) == 0 {
67+
return fmt.Errorf("at least one location is required (or use --from-json)")
68+
}
69+
applied, err = runFromLocations(u, args, force)
6770
}
68-
69-
data, err := protojson.MarshalOptions{
70-
Multiline: true,
71-
Indent: " ",
72-
}.Marshal(set)
7371
if err != nil {
74-
return fmt.Errorf("marshaling policy data: %w", err)
75-
}
76-
77-
var out io.Writer = os.Stdout
78-
if _, err := fmt.Fprintln(out, string(data)); err != nil {
7972
return err
8073
}
81-
74+
reportApplied(applied)
8275
return nil
8376
},
8477
}
85-
opts.AddFlags(updateCmd)
78+
updateCmd.Flags().StringVar(
79+
&fromJSON, "from-json", "",
80+
"apply updates from a plan file produced by 'check-update --format=json'",
81+
)
82+
updateCmd.Flags().BoolVar(
83+
&force, "force", false,
84+
"skip the confirmation prompt and apply updates directly",
85+
)
8686
parentCmd.AddCommand(updateCmd)
8787
}
88+
89+
// runFromLocations handles the default path: check for updates, show
90+
// them, ask for confirmation, then apply. When force is set, skips both
91+
// the table and the prompt and delegates to Updater.Update.
92+
func runFromLocations(u *policy.Updater, args []string, force bool) (map[string][]*policy.RefUpdate, error) {
93+
if force {
94+
return u.Update(args...)
95+
}
96+
97+
updates, err := u.CheckUpdates(args...)
98+
if err != nil {
99+
return nil, err
100+
}
101+
if len(updates) == 0 {
102+
fmt.Fprintln(os.Stderr, "no policy references have updates available")
103+
return nil, nil
104+
}
105+
printUpdatesTable(os.Stdout, updates)
106+
if !confirm("Apply these updates?") {
107+
fmt.Fprintln(os.Stderr, "aborted; no files were modified")
108+
return nil, nil
109+
}
110+
return u.ApplyUpdates(updates)
111+
}
112+
113+
// runFromJSON handles the --from-json path: decode the plan, show it,
114+
// optionally prompt, then apply.
115+
func runFromJSON(u *policy.Updater, path string, force bool) (map[string][]*policy.RefUpdate, error) {
116+
updates, err := loadPlanFile(path)
117+
if err != nil {
118+
return nil, err
119+
}
120+
if len(updates) == 0 {
121+
fmt.Fprintln(os.Stderr, "plan file contains no updates to apply")
122+
return nil, nil
123+
}
124+
if !force {
125+
printUpdatesTable(os.Stdout, updates)
126+
if !confirm("Apply these updates?") {
127+
fmt.Fprintln(os.Stderr, "aborted; no files were modified")
128+
return nil, nil
129+
}
130+
}
131+
return u.ApplyUpdates(updates)
132+
}
133+
134+
func reportApplied(applied map[string][]*policy.RefUpdate) {
135+
if len(applied) == 0 {
136+
fmt.Fprintln(os.Stderr, "no policy references needed updating")
137+
return
138+
}
139+
files := make([]string, 0, len(applied))
140+
for f := range applied {
141+
files = append(files, f)
142+
}
143+
sort.Strings(files)
144+
for _, f := range files {
145+
fmt.Fprintf(os.Stdout, "updated %s (%d reference(s))\n", f, len(applied[f])) //nolint:errcheck // stdout write errors are not actionable here
146+
for _, r := range applied[f] {
147+
fmt.Fprintf(os.Stdout, " - %s\n", r.Old.GetLocation().GetUri()) //nolint:errcheck // stdout write errors are not actionable here
148+
}
149+
}
150+
}
151+
152+
func confirm(prompt string) bool {
153+
fmt.Fprintf(os.Stderr, "%s [y/N]: ", prompt)
154+
r := bufio.NewReader(os.Stdin)
155+
line, err := r.ReadString('\n')
156+
if err != nil {
157+
return false
158+
}
159+
line = strings.TrimSpace(strings.ToLower(line))
160+
return line == "y" || line == "yes"
161+
}
162+
163+
// loadPlanFile decodes the JSON plan produced by check-update into the
164+
// map shape accepted by Updater.ApplyUpdates.
165+
func loadPlanFile(path string) (map[string][]*policy.RefUpdate, error) {
166+
data, err := os.ReadFile(path)
167+
if err != nil {
168+
return nil, fmt.Errorf("reading plan file: %w", err)
169+
}
170+
171+
var plan updatePlan
172+
if err := json.Unmarshal(data, &plan); err != nil {
173+
return nil, fmt.Errorf("decoding plan file: %w", err)
174+
}
175+
176+
out := map[string][]*policy.RefUpdate{}
177+
for file, entries := range plan.Files {
178+
refs := make([]*policy.RefUpdate, 0, len(entries))
179+
for i, e := range entries {
180+
oldRef := &api.PolicyRef{}
181+
if err := protojson.Unmarshal(e.Old, oldRef); err != nil {
182+
return nil, fmt.Errorf("decoding old ref %d for %s: %w", i, file, err)
183+
}
184+
newRef := &api.PolicyRef{}
185+
if err := protojson.Unmarshal(e.New, newRef); err != nil {
186+
return nil, fmt.Errorf("decoding new ref %d for %s: %w", i, file, err)
187+
}
188+
refs = append(refs, &policy.RefUpdate{Old: oldRef, New: newRef})
189+
}
190+
out[file] = refs
191+
}
192+
return out, nil
193+
}

0 commit comments

Comments
 (0)