Skip to content

Commit 6457607

Browse files
committed
feat: add log and restore commands for config revision history
- openboot log [--slug]: list revision history for a config - openboot restore <id> [--slug] [--dry-run] [--yes]: restore packages to a prior revision, syncs local system after restore - openboot push -m <msg>: attach a revision message when pushing - E2E VM tests covering log output, restore dry-run, restore actual apply, and restore with --slug flag
1 parent 9645c6a commit 6457607

File tree

5 files changed

+861
-7
lines changed

5 files changed

+861
-7
lines changed

internal/cli/log.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"net/url"
9+
"time"
10+
11+
"github.com/openbootdotdev/openboot/internal/auth"
12+
"github.com/openbootdotdev/openboot/internal/httputil"
13+
syncpkg "github.com/openbootdotdev/openboot/internal/sync"
14+
"github.com/openbootdotdev/openboot/internal/ui"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
var logCmd = &cobra.Command{
19+
Use: "log",
20+
Short: "Show revision history for your config",
21+
Long: `List the revision history for your synced config on openboot.dev.
22+
23+
Each time you run 'openboot push', the previous version is saved as a revision.
24+
Use 'openboot restore <revision-id>' to roll back to a previous state.`,
25+
Example: ` # Show revision history for your current config
26+
openboot log
27+
28+
# Show history for a specific config slug
29+
openboot log --slug my-config`,
30+
SilenceUsage: true,
31+
RunE: func(cmd *cobra.Command, args []string) error {
32+
slug, _ := cmd.Flags().GetString("slug")
33+
return runLog(slug)
34+
},
35+
}
36+
37+
func init() {
38+
logCmd.Flags().String("slug", "", "config slug to show history for (default: current sync source)")
39+
rootCmd.AddCommand(logCmd)
40+
}
41+
42+
type revisionSummary struct {
43+
ID string `json:"id"`
44+
Message *string `json:"message"`
45+
CreatedAt string `json:"created_at"`
46+
PackageCount int `json:"package_count"`
47+
}
48+
49+
func runLog(slugOverride string) error {
50+
apiBase := auth.GetAPIBase()
51+
52+
if !auth.IsAuthenticated() {
53+
return fmt.Errorf("not logged in — run 'openboot login' first")
54+
}
55+
56+
stored, err := auth.LoadToken()
57+
if err != nil {
58+
return fmt.Errorf("load auth token: %w", err)
59+
}
60+
if stored == nil {
61+
return fmt.Errorf("no valid auth token found — please log in again")
62+
}
63+
64+
slug := slugOverride
65+
if slug == "" {
66+
source, loadErr := syncpkg.LoadSource()
67+
if loadErr == nil && source != nil && source.Slug != "" {
68+
slug = source.Slug
69+
}
70+
}
71+
if slug == "" {
72+
return fmt.Errorf("no config slug — use --slug or run 'openboot install <config>' first")
73+
}
74+
75+
reqURL := fmt.Sprintf("%s/api/configs/%s/revisions", apiBase, url.PathEscape(slug))
76+
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
77+
if err != nil {
78+
return fmt.Errorf("build request: %w", err)
79+
}
80+
req.Header.Set("Authorization", "Bearer "+stored.Token)
81+
82+
client := &http.Client{Timeout: 15 * time.Second}
83+
resp, err := httputil.Do(client, req)
84+
if err != nil {
85+
return fmt.Errorf("fetch revisions: %w", err)
86+
}
87+
defer resp.Body.Close()
88+
89+
if resp.StatusCode == http.StatusNotFound {
90+
return fmt.Errorf("config %q not found", slug)
91+
}
92+
if resp.StatusCode != http.StatusOK {
93+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
94+
return fmt.Errorf("fetch revisions failed (status %d): %s", resp.StatusCode, string(body))
95+
}
96+
97+
var result struct {
98+
Revisions []revisionSummary `json:"revisions"`
99+
}
100+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
101+
return fmt.Errorf("parse response: %w", err)
102+
}
103+
104+
fmt.Println()
105+
ui.Header(fmt.Sprintf("Revision history: %s", slug))
106+
fmt.Println()
107+
108+
if len(result.Revisions) == 0 {
109+
ui.Muted(" No revisions yet. Push a config update to create one.")
110+
fmt.Println()
111+
return nil
112+
}
113+
114+
for _, rev := range result.Revisions {
115+
ts := rev.CreatedAt
116+
if t, parseErr := time.Parse("2006-01-02T15:04:05Z", rev.CreatedAt); parseErr == nil {
117+
ts = t.Local().Format("2006-01-02 15:04")
118+
} else if t2, parseErr2 := time.Parse("2006-01-02 15:04:05", rev.CreatedAt); parseErr2 == nil {
119+
ts = t2.Local().Format("2006-01-02 15:04")
120+
}
121+
122+
msg := ""
123+
if rev.Message != nil && *rev.Message != "" {
124+
msg = fmt.Sprintf(" %s", *rev.Message)
125+
}
126+
127+
fmt.Printf(" %s %s %d pkgs%s\n",
128+
ui.Cyan(rev.ID),
129+
ts,
130+
rev.PackageCount,
131+
msg,
132+
)
133+
}
134+
135+
fmt.Println()
136+
ui.Muted("Use 'openboot restore <revision-id>' to roll back to a previous state.")
137+
fmt.Println()
138+
139+
return nil
140+
}

internal/cli/push.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,23 @@ Use --slug to target a specific existing config.`,
4444
Args: cobra.RangeArgs(0, 1),
4545
RunE: func(cmd *cobra.Command, args []string) error {
4646
slug, _ := cmd.Flags().GetString("slug")
47+
message, _ := cmd.Flags().GetString("message")
4748
if len(args) == 0 {
48-
return runPushAuto(slug)
49+
return runPushAuto(slug, message)
4950
}
50-
return runPush(args[0], slug)
51+
return runPush(args[0], slug, message)
5152
},
5253
}
5354

5455
func init() {
5556
pushCmd.Flags().String("slug", "", "update an existing config by slug")
57+
pushCmd.Flags().StringP("message", "m", "", "revision message (saved in history when updating)")
5658
rootCmd.AddCommand(pushCmd)
5759
}
5860

5961
// runPushAuto captures the current system snapshot and uploads it to openboot.dev.
6062
// If a sync source is configured, it updates that config; otherwise, creates a new one.
61-
func runPushAuto(slugOverride string) error {
63+
func runPushAuto(slugOverride, message string) error {
6264
apiBase := auth.GetAPIBase()
6365

6466
if !auth.IsAuthenticated() {
@@ -99,10 +101,10 @@ func runPushAuto(slugOverride string) error {
99101
}
100102
}
101103

102-
return pushSnapshot(data, slug, stored.Token, stored.Username, apiBase)
104+
return pushSnapshot(data, slug, message, stored.Token, stored.Username, apiBase)
103105
}
104106

105-
func runPush(filePath, slug string) error {
107+
func runPush(filePath, slug, message string) error {
106108
apiBase := auth.GetAPIBase()
107109

108110
if !auth.IsAuthenticated() {
@@ -136,12 +138,12 @@ func runPush(filePath, slug string) error {
136138
}
137139

138140
if probe.CapturedAt != "" {
139-
return pushSnapshot(data, slug, stored.Token, stored.Username, apiBase)
141+
return pushSnapshot(data, slug, message, stored.Token, stored.Username, apiBase)
140142
}
141143
return pushConfig(data, slug, stored.Token, stored.Username, apiBase)
142144
}
143145

144-
func pushSnapshot(data []byte, slug, token, username, apiBase string) error {
146+
func pushSnapshot(data []byte, slug, message, token, username, apiBase string) error {
145147
var snap snapshot.Snapshot
146148
if err := json.Unmarshal(data, &snap); err != nil {
147149
return fmt.Errorf("parse snapshot: %w", err)
@@ -161,6 +163,9 @@ func pushSnapshot(data []byte, slug, token, username, apiBase string) error {
161163
if slug != "" {
162164
reqBody["config_slug"] = slug
163165
}
166+
if message != "" {
167+
reqBody["message"] = message
168+
}
164169

165170
bodyBytes, err := json.Marshal(reqBody)
166171
if err != nil {

0 commit comments

Comments
 (0)