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"` // 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.Op != ""} { if set { n++ } } if n != 1 { return nil, fmt.Errorf("change %d: use exactly one of add, delete, set, update 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: d := e.Delete if id, ok := toInt64(d["id"]); ok { delete(d, "id") out = append(out, api.ChangeRequest{Op: "record.delete", ID: id, Data: rawJSON(d)}) } else { out = append(out, api.ChangeRequest{Op: "record.delete", Data: rawJSON(d)}) } 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 } // 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 if c.Changeset > 0 { if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", c.Changeset), 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 jarl changeset apply $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)\n", cs.ID, cs.ID, cs.ID) return nil }, } show := &cobra.Command{ Use: "show ID", Short: "Show a changeset's operations", 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.Get(ctx(), "/changesets/"+args[0], 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", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var raw json.RawMessage if err := c.Get(ctx(), "/changesets/"+args[0]+"/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 { fmt.Println("\nvalid: apply with 'jarl changeset apply " + args[0] + "'") 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", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var resp api.MutationResponse if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil { return err } return report(&resp) }, } discard := &cobra.Command{ Use: "discard ID", Short: "Throw away an open changeset", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/discard", nil, nil, nil); err != nil { return err } if !flagQuiet { fmt.Println("discarded changeset", args[0]) } return nil }, } remove := &cobra.Command{ Use: "remove ID SEQ", Short: "Remove one operation from an open changeset", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } return c.Do(ctx(), "DELETE", "/changesets/"+args[0]+"/ops/"+args[1], 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 }