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>
460 lines
14 KiB
Go
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
|
|
}
|