|
| 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 | +} |
0 commit comments