release / release (push) Failing after 18s
Created and Applied were printed in UTC, while history and status print local time. 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, AcceptRisk: c.AcceptRisk}, &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.Local().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.Local().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, "accept_risk": c.AcceptRisk}, &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
|
|
}
|