Files
jarl/cmd/jarl/changes.go
T
adamandClaude Opus 5.5 cee11e7c86 CLI: changeset commands default to JARL_CHANGESET, which is checked first
jarl changeset show/diff/apply/discard take the changeset from -C or
JARL_CHANGESET when no ID is given, and remove takes [ID] SEQ. A changeset
from JARL_CHANGESET is checked before anything uses it (staging included):
it must exist, be open and be the caller's own, so a stale export can't
stage into an applied changeset or into another user's draft that later
got its ID. Commands say on stderr which changeset they use, and apply and
discard remind to unset JARL_CHANGESET. -C and ID arguments are used as
given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:53:26 +01:00

460 lines
14 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"go.yaml.in/yaml/v3"
"jarl/internal/api"
"jarl/internal/client"
)
// batchFile is the format of 'jarl apply -f'.
type batchFile struct {
Description string `yaml:"description" json:"description"`
Changes []batchEntry `yaml:"changes" json:"changes"`
}
type batchEntry struct {
Add map[string]any `yaml:"add,omitempty" json:"add,omitempty"`
Delete map[string]any `yaml:"delete,omitempty" json:"delete,omitempty"`
Set map[string]any `yaml:"set,omitempty" json:"set,omitempty"`
Update map[string]any `yaml:"update,omitempty" json:"update,omitempty"`
// Disable and Enable find records like Delete: by id, or by zone,
// name, type and optionally data.
Disable map[string]any `yaml:"disable,omitempty" json:"disable,omitempty"`
Enable map[string]any `yaml:"enable,omitempty" json:"enable,omitempty"`
// Any API operation: op, id, data.
Op string `yaml:"op,omitempty" json:"op,omitempty"`
ID int64 `yaml:"id,omitempty" json:"id,omitempty"`
Data map[string]any `yaml:"data,omitempty" json:"data,omitempty"`
}
func toInt64(v any) (int64, bool) {
switch n := v.(type) {
case int:
return int64(n), true
case int64:
return n, true
case float64:
return int64(n), true
case string:
i, err := strconv.ParseInt(n, 10, 64)
return i, err == nil
}
return 0, false
}
func rawJSON(v any) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
// requests converts the file's entries into API change requests.
func (b *batchFile) requests() ([]api.ChangeRequest, error) {
var out []api.ChangeRequest
for i, e := range b.Changes {
n := 0
for _, set := range []bool{e.Add != nil, e.Delete != nil, e.Set != nil, e.Update != nil, e.Disable != nil, e.Enable != nil, e.Op != ""} {
if set {
n++
}
}
if n != 1 {
return nil, fmt.Errorf("change %d: use exactly one of add, delete, set, update, disable, enable or op", i+1)
}
switch {
case e.Add != nil:
out = append(out, api.ChangeRequest{Op: "record.create", Data: rawJSON(e.Add)})
case e.Delete != nil:
out = append(out, byIDOrMatch("record.delete", e.Delete))
case e.Disable != nil:
out = append(out, byIDOrMatch("record.disable", e.Disable))
case e.Enable != nil:
out = append(out, byIDOrMatch("record.enable", e.Enable))
case e.Set != nil:
s := e.Set
switch d := s["data"].(type) {
case string:
s["data"] = []string{d}
case nil:
s["data"] = []string{}
}
out = append(out, api.ChangeRequest{Op: "rrset.replace", Data: rawJSON(s)})
case e.Update != nil:
u := e.Update
id, ok := toInt64(u["id"])
if !ok {
return nil, fmt.Errorf("change %d: update needs the record id", i+1)
}
delete(u, "id")
out = append(out, api.ChangeRequest{Op: "record.update", ID: id, Data: rawJSON(u)})
default:
var data json.RawMessage
if e.Data != nil {
data = rawJSON(e.Data)
}
out = append(out, api.ChangeRequest{Op: e.Op, ID: e.ID, Data: data})
}
}
return out, nil
}
// byIDOrMatch is a request for the record with d's id, or for the records
// d matches.
func byIDOrMatch(op string, d map[string]any) api.ChangeRequest {
if id, ok := toInt64(d["id"]); ok {
delete(d, "id")
return api.ChangeRequest{Op: op, ID: id, Data: rawJSON(d)}
}
return api.ChangeRequest{Op: op, Data: rawJSON(d)}
}
// submitBatch applies requests as one changeset, or stages them into the
// changeset given with --changeset.
func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error {
var resp api.MutationResponse
cs, err := c.UseChangeset(ctx())
if err != nil {
return err
}
if cs > 0 {
if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", cs), nil, map[string]any{"ops": reqs}, &resp); err != nil {
return err
}
return report(&resp)
}
if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force}, &resp); err != nil {
return err
}
return report(&resp)
}
func applyCmd() *cobra.Command {
var file, desc string
cmd := &cobra.Command{
Use: "apply -f FILE",
Short: "Apply a batch of changes from a YAML or JSON file, all or nothing",
Long: `Applies the changes in FILE ('-' for standard input) as one changeset: they
are validated together and either all are applied or none.
description: move the web server
changes:
- add: {zone: example.com, name: www2, type: A, data: 192.0.2.20, ttl: 300, ptr: true}
- delete: {zone: example.com, name: www, type: A} # every A record of www
- delete: {zone: example.com, name: old, type: CNAME, data: www} # just this record
- delete: {id: 1234}
- set: {zone: example.com, name: api, type: A, data: [192.0.2.30, 192.0.2.31], ttl: 60}
- update: {id: 1235, data: 192.0.2.40}
- op: zone.create # any API operation
data: {name: lab.example.com, nameservers: [ns1.example.com.]}
Records name zones by name (add "view: NAME" when the zone exists in several
views). With --changeset the changes are staged instead; with --dry-run they
are only validated.`,
RunE: func(cmd *cobra.Command, args []string) error {
if file == "" {
return fmt.Errorf("-f FILE is required")
}
var data []byte
var err error
if file == "-" {
data, err = io.ReadAll(os.Stdin)
} else {
data, err = os.ReadFile(file)
}
if err != nil {
return err
}
var b batchFile
if err := yaml.Unmarshal(data, &b); err != nil {
return fmt.Errorf("%s: %w", file, err)
}
if desc != "" {
b.Description = desc
}
reqs, err := b.requests()
if err != nil {
return err
}
if len(reqs) == 0 {
return fmt.Errorf("%s has no changes", file)
}
c, err := newClient()
if err != nil {
return err
}
return submitBatch(c, b.Description, reqs)
},
}
cmd.Flags().StringVarP(&file, "file", "f", "", "file with the changes ('-' for standard input)")
cmd.Flags().StringVar(&desc, "description", "", "description for the history")
return cmd
}
func changesetCmd() *cobra.Command {
cmd := &cobra.Command{Use: "changeset", Aliases: []string{"cs"}, Short: "Stage changes and apply them together"}
var status string
var mine bool
list := &cobra.Command{
Use: "list",
Short: "List changesets (open ones by default)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
qv := q("status", status)
if mine {
qv.Set("mine", "1")
}
var list []api.Changeset
if err := c.Get(ctx(), "/changesets", qv, &list); err != nil {
return err
}
return output(list, func(w io.Writer) {
fmt.Fprintln(w, "ID\tSTATUS\tUSER\tVIA\tOPS\tDESCRIPTION")
for _, cs := range list {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", cs.ID, cs.Status, cs.Username, cs.Via, len(cs.Ops), cs.Description)
}
})
},
}
list.Flags().StringVar(&status, "status", "open", "open, applied or discarded (empty for all)")
list.Flags().BoolVar(&mine, "mine", false, "only my changesets")
create := &cobra.Command{
Use: "create [DESCRIPTION]",
Short: "Start a changeset; prints its ID",
Example: ` export JARL_CHANGESET=$(jarl changeset create "move web servers" -q)
jarl record add example.com www2 A 192.0.2.20
jarl record delete example.com www A
jarl changeset diff # JARL_CHANGESET's
jarl changeset apply
unset JARL_CHANGESET`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cs api.Changeset
if err := c.Do(ctx(), "POST", "/changesets", nil, map[string]any{"description": strings.Join(args, " ")}, &cs); err != nil {
return err
}
if flagQuiet {
fmt.Println(cs.ID)
return nil
}
fmt.Printf("created changeset %d\nstage changes with --changeset %d (or export JARL_CHANGESET=%d), then 'jarl changeset apply'\n", cs.ID, cs.ID, cs.ID)
return nil
},
}
show := &cobra.Command{
Use: "show [ID]",
Short: "Show a changeset's operations (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, _, err := changesetArg(c, args, false)
if err != nil {
return err
}
var cs api.Changeset
if err := c.Get(ctx(), "/changesets/"+id, nil, &cs); err != nil {
return err
}
return output(cs, func(w io.Writer) {
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Format("2006-01-02 15:04"), cs.Username, cs.Via)
if cs.AppliedAt != nil {
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Format("2006-01-02 15:04"), cs.AppliedBy)
}
fmt.Fprintln(w, "\n#\tOPERATION")
for _, op := range cs.Ops {
fmt.Fprintf(w, "%d\t%s\n", op.Seq, op.Summary)
}
if cs.Diff != "" {
w.(interface{ Flush() error }).Flush()
fmt.Println()
fmt.Print(cs.Diff)
}
})
},
}
diff := &cobra.Command{
Use: "diff [ID]",
Short: "Validate an open changeset and show what applying it would change (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, fromEnv, err := changesetArg(c, args, true)
if err != nil {
return err
}
var raw json.RawMessage
if err := c.Get(ctx(), "/changesets/"+id+"/preview", nil, &raw); err != nil {
return err
}
if flagOutput != "table" {
var v any
json.Unmarshal(raw, &v)
return output(v, nil)
}
r := client.ParseResult(raw)
for _, op := range r.Ops {
fmt.Printf("#%d %s\n", op.Seq, op.Summary)
}
if r.Error != nil {
fmt.Println("error:", r.Error.Message)
}
printProblems(os.Stdout, r.Problems)
printCheck(os.Stdout, r.Check)
for _, w := range r.Warnings {
fmt.Println("warning:", w)
}
fmt.Println()
fmt.Print(r.Diff)
if r.Valid {
how := "jarl changeset apply " + id
if fromEnv {
how = "jarl changeset apply"
}
fmt.Println("\nvalid: apply with '" + how + "'")
return nil
}
return fmt.Errorf("the changeset can't be applied as it is")
},
}
apply := &cobra.Command{
Use: "apply [ID]",
Short: "Apply an open changeset (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, fromEnv, err := changesetArg(c, args, true)
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil {
return err
}
if err := report(&resp); err != nil {
return err
}
if fromEnv && resp.Applied {
envChangesetDone(id, "applied")
}
return nil
},
}
discard := &cobra.Command{
Use: "discard [ID]",
Short: "Throw away an open changeset (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, fromEnv, err := changesetArg(c, args, true)
if err != nil {
return err
}
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/discard", nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("discarded changeset", id)
}
if fromEnv {
envChangesetDone(id, "discarded")
}
return nil
},
}
remove := &cobra.Command{
Use: "remove [ID] SEQ",
Short: "Remove one operation from an open changeset (default: -C or JARL_CHANGESET)",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
seq := args[len(args)-1]
id, _, err := changesetArg(c, args[:len(args)-1], true)
if err != nil {
return err
}
return c.Do(ctx(), "DELETE", "/changesets/"+id+"/ops/"+seq, nil, nil, nil)
},
}
revert := &cobra.Command{
Use: "revert ID",
Short: "Create a changeset that undoes an applied changeset (review it, then apply)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cs api.Changeset
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/revert", nil, nil, &cs); err != nil {
return err
}
if flagQuiet {
fmt.Println(cs.ID)
return nil
}
fmt.Printf("created changeset %d with %d operations; review with 'jarl changeset diff %d', apply with 'jarl changeset apply %d'\n", cs.ID, len(cs.Ops), cs.ID, cs.ID)
return nil
},
}
cmd.AddCommand(list, create, show, diff, apply, discard, remove, revert)
return cmd
}
func historyCmd() *cobra.Command {
var limit int
cmd := &cobra.Command{
Use: "history",
Short: "List applied changesets",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var list []api.Changeset
if err := c.Get(ctx(), "/changesets", q("status", "applied", "limit", fmt.Sprint(limit)), &list); err != nil {
return err
}
return output(list, func(w io.Writer) {
fmt.Fprintln(w, "ID\tAPPLIED\tBY\tVIA\tOPS\tDESCRIPTION")
for _, cs := range list {
at := ""
if cs.AppliedAt != nil {
at = cs.AppliedAt.Local().Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", cs.ID, at, cs.AppliedBy, cs.Via, len(cs.Ops), cs.Description)
}
})
},
}
cmd.Flags().IntVar(&limit, "limit", 30, "how many")
return cmd
}