influxdb/cmd/influx/pkg.go

795 lines
21 KiB
Go
Raw Normal View History

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/fatih/color"
"github.com/influxdata/influxdb"
ihttp "github.com/influxdata/influxdb/http"
ierror "github.com/influxdata/influxdb/kit/errors"
"github.com/influxdata/influxdb/pkger"
"github.com/olekukonko/tablewriter"
"github.com/spf13/cobra"
input "github.com/tcnksm/go-input"
"gopkg.in/yaml.v3"
)
type pkgSVCFn func(cliReq httpClientOpts) (pkger.SVC, error)
func cmdPkg(newSVCFn pkgSVCFn) *cobra.Command {
cmd := cmdPkgApply(newSVCFn)
cmd.AddCommand(
cmdPkgNew(newSVCFn),
cmdPkgExport(newSVCFn),
cmdPkgValidate(),
)
return cmd
}
type pkgApplyOpts struct {
orgID, file string
hasColor, hasTableBorders bool
quiet, forceOnConflict bool
}
func cmdPkgApply(newSVCFn pkgSVCFn) *cobra.Command {
cmd := &cobra.Command{
Use: "pkg",
Short: "Apply a pkg to create resources",
}
var opts pkgApplyOpts
cmd.Flags().StringVarP(&opts.file, "file", "f", "", "Path to package file")
cmd.MarkFlagFilename("file", "yaml", "yml", "json")
cmd.Flags().BoolVar(&opts.forceOnConflict, "force-on-conflict", true, "TTY input, if package will have destructive changes, proceed if set true.")
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false, "disable output printing")
cmd.Flags().StringVarP(&opts.orgID, "org-id", "o", "", "The ID of the organization that owns the bucket")
cmd.MarkFlagRequired("org-id")
cmd.Flags().BoolVarP(&opts.hasColor, "color", "c", true, "Enable color in output, defaults true")
cmd.Flags().BoolVar(&opts.hasTableBorders, "table-borders", true, "Enable table borders, defaults true")
cmd.RunE = pkgApplyRunEFn(newSVCFn, &opts)
return cmd
}
func readPkgStdInOrFile(file string) (*pkger.Pkg, bool, error) {
if stdin, _ := readStdIn(); stdin != nil {
pkg, err := pkgFromStdIn(stdin)
return pkg, true, err
}
if file == "" {
return nil, false, errors.New("a file path is required when not using a TTY input")
}
pkg, err := pkgFromFile(file)
return pkg, false, err
}
func pkgApplyRunEFn(newSVCFn pkgSVCFn, opts *pkgApplyOpts) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) (e error) {
if !opts.hasColor {
color.NoColor = true
}
influxOrgID, err := influxdb.IDFromString(opts.orgID)
if err != nil {
return err
}
svc, err := newSVCFn(flags.httpClientOpts())
if err != nil {
return err
}
pkg, isTTY, err := readPkgStdInOrFile(opts.file)
if err != nil {
return err
}
_, diff, err := svc.DryRun(context.Background(), *influxOrgID, pkg)
if err != nil {
return err
}
if !opts.quiet {
printPkgDiff(opts.hasColor, opts.hasTableBorders, diff)
}
if !isTTY {
ui := &input.UI{
Writer: os.Stdout,
Reader: os.Stdin,
}
confirm := getInput(ui, "Confirm application of the above resources (y/n)", "n")
if strings.ToLower(confirm) != "y" {
fmt.Fprintln(os.Stdout, "aborted application of package")
return nil
}
}
if !opts.forceOnConflict && isTTY && diff.HasConflicts() {
return errors.New("package has conflicts with existing resources and cannot safely apply")
}
summary, err := svc.Apply(context.Background(), *influxOrgID, pkg)
if err != nil {
return err
}
if !opts.quiet {
printPkgSummary(opts.hasColor, opts.hasTableBorders, summary)
}
return nil
}
}
func cmdPkgNew(newSVCFn pkgSVCFn) *cobra.Command {
cmd := &cobra.Command{
Use: "new",
Short: "Create a reusable pkg to create resources in a declarative manner",
}
var opts pkgNewOpts
cmd.Flags().StringVarP(&opts.outPath, "file", "f", "", "output file for created pkg; defaults to std out if no file provided; the extension of provided file (.yml/.json) will dictate encoding")
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false, "skip interactive mode")
cmd.Flags().StringVarP(&opts.meta.Name, "name", "n", "", "name for new pkg")
cmd.Flags().StringVarP(&opts.meta.Description, "description", "d", "", "description for new pkg")
cmd.Flags().StringVarP(&opts.meta.Version, "version", "v", "", "version for new pkg")
cmd.RunE = pkgNewRunEFn(newSVCFn, &opts)
return cmd
}
type pkgNewOpts struct {
quiet bool
outPath string
orgID string
meta pkger.Metadata
}
func pkgNewRunEFn(newSVCFn pkgSVCFn, opt *pkgNewOpts) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
if !opt.quiet {
ui := &input.UI{
Writer: os.Stdout,
Reader: os.Stdin,
}
if opt.meta.Name == "" {
opt.meta.Name = getInput(ui, "pkg name", "")
}
if opt.meta.Description == "" {
opt.meta.Description = getInput(ui, "pkg description", opt.meta.Description)
}
if opt.meta.Version == "" {
opt.meta.Version = getInput(ui, "pkg version", opt.meta.Version)
}
}
pkgSVC, err := newSVCFn(flags.httpClientOpts())
if err != nil {
return err
}
return writePkg(cmd.OutOrStdout(), pkgSVC, opt.outPath, pkger.CreateWithMetadata(opt.meta))
}
}
type pkgExportOpts struct {
outPath string
meta pkger.Metadata
resourceType string
buckets string
dashboards string
labels string
variables string
}
func cmdPkgExport(newSVCFn pkgSVCFn) *cobra.Command {
cmd := &cobra.Command{
Use: "export",
Short: "Export existing resources as a package",
}
cmd.AddCommand(cmdPkgExportAll(newSVCFn))
var opts pkgExportOpts
cmd.Flags().StringVarP(&opts.outPath, "file", "f", "", "output file for created pkg; defaults to std out if no file provided; the extension of provided file (.yml/.json) will dictate encoding")
cmd.Flags().StringVarP(&opts.meta.Name, "name", "n", "", "name for new pkg")
cmd.Flags().StringVarP(&opts.meta.Description, "description", "d", "", "description for new pkg")
cmd.Flags().StringVarP(&opts.meta.Version, "version", "v", "", "version for new pkg")
cmd.Flags().StringVar(&opts.resourceType, "resource-type", "", "The resource type provided will be associated with all IDs via stdin.")
cmd.Flags().StringVar(&opts.buckets, "buckets", "", "List of bucket ids comma separated")
cmd.Flags().StringVar(&opts.dashboards, "dashboards", "", "List of dashboard ids comma separated")
cmd.Flags().StringVar(&opts.labels, "labels", "", "List of label ids comma separated")
cmd.Flags().StringVar(&opts.variables, "variables", "", "List of variable ids comma separated")
cmd.RunE = pkgExportRunEFn(newSVCFn, &opts)
return cmd
}
func pkgExportRunEFn(newSVCFn pkgSVCFn, cmdOpts *pkgExportOpts) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
pkgSVC, err := newSVCFn(flags.httpClientOpts())
if err != nil {
return err
}
opts := []pkger.CreatePkgSetFn{pkger.CreateWithMetadata(cmdOpts.meta)}
resTypes := []struct {
kind pkger.Kind
idStrs []string
}{
{kind: pkger.KindBucket, idStrs: strings.Split(cmdOpts.buckets, ",")},
{kind: pkger.KindDashboard, idStrs: strings.Split(cmdOpts.dashboards, ",")},
{kind: pkger.KindLabel, idStrs: strings.Split(cmdOpts.labels, ",")},
{kind: pkger.KindVariable, idStrs: strings.Split(cmdOpts.variables, ",")},
}
for _, rt := range resTypes {
newOpt, err := getResourcesToClone(rt.kind, rt.idStrs)
if err != nil {
return ierror.Wrap(err, rt.kind.String())
}
opts = append(opts, newOpt)
}
if cmdOpts.resourceType == "" {
return writePkg(cmd.OutOrStdout(), pkgSVC, cmdOpts.outPath, opts...)
}
kind := pkger.NewKind(cmdOpts.resourceType)
if err := kind.OK(); err != nil {
return errors.New("resource type must be one of bucket|dashboard|label|variable; got: " + cmdOpts.resourceType)
}
stdinInpt, _ := readStdInLines()
if len(stdinInpt) > 0 {
args = stdinInpt
}
resTypeOpt, err := getResourcesToClone(kind, args)
if err != nil {
return err
}
return writePkg(cmd.OutOrStdout(), pkgSVC, cmdOpts.outPath, append(opts, resTypeOpt)...)
}
}
func getResourcesToClone(kind pkger.Kind, idStrs []string) (pkger.CreatePkgSetFn, error) {
ids, err := toInfluxIDs(idStrs)
if err != nil {
return nil, err
}
var resources []pkger.ResourceToClone
for _, id := range ids {
resources = append(resources, pkger.ResourceToClone{
Kind: kind,
ID: id,
})
}
return pkger.CreateWithExistingResources(resources...), nil
}
func cmdPkgExportAll(newSVCFn pkgSVCFn) *cobra.Command {
cmd := &cobra.Command{
Use: "all",
Short: "Export all existing resources for an organization as a package",
}
var opts pkgNewOpts
cmd.Flags().StringVarP(&opts.outPath, "file", "f", "", "output file for created pkg; defaults to std out if no file provided; the extension of provided file (.yml/.json) will dictate encoding")
cmd.Flags().StringVarP(&opts.orgID, "org-id", "o", "", "organization id")
cmd.Flags().StringVarP(&opts.meta.Name, "name", "n", "", "name for new pkg")
cmd.Flags().StringVarP(&opts.meta.Description, "description", "d", "", "description for new pkg")
cmd.Flags().StringVarP(&opts.meta.Version, "version", "v", "", "version for new pkg")
cmd.RunE = pkgExportAllRunEFn(newSVCFn, &opts)
return cmd
}
func pkgExportAllRunEFn(newSVCFn pkgSVCFn, opt *pkgNewOpts) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
pkgSVC, err := newSVCFn(flags.httpClientOpts())
if err != nil {
return err
}
opts := []pkger.CreatePkgSetFn{pkger.CreateWithMetadata(opt.meta)}
orgID, err := influxdb.IDFromString(opt.orgID)
if err != nil {
return err
}
opts = append(opts, pkger.CreateWithAllOrgResources(*orgID))
return writePkg(cmd.OutOrStdout(), pkgSVC, opt.outPath, opts...)
}
}
func cmdPkgValidate() *cobra.Command {
cmd := &cobra.Command{
Use: "validate",
Short: "Validate the provided package",
}
inPath := cmd.Flags().StringP("file", "f", "", "input file for pkg; if none provided will use TTY input")
cmd.RunE = func(cmd *cobra.Command, args []string) error {
pkg, _, err := readPkgStdInOrFile(*inPath)
if err != nil {
return err
}
return pkg.Validate()
}
return cmd
}
func readStdIn() (*os.File, error) {
info, err := os.Stdin.Stat()
if err != nil {
return nil, err
}
if (info.Mode() & os.ModeCharDevice) == os.ModeCharDevice {
return nil, nil
}
return os.Stdin, nil
}
func readStdInLines() ([]string, error) {
r, err := readStdIn()
if err != nil || r == nil {
return nil, err
}
b, err := ioutil.ReadAll(r)
if err != nil {
return nil, err
}
var stdinInput []string
for _, bb := range bytes.Split(b, []byte("\n")) {
trimmed := bytes.TrimSpace(bb)
if len(trimmed) == 0 {
continue
}
stdinInput = append(stdinInput, string(trimmed))
}
return stdinInput, nil
}
func toInfluxIDs(args []string) ([]influxdb.ID, error) {
var (
ids []influxdb.ID
errs []string
)
for _, arg := range args {
normedArg := strings.TrimSpace(strings.ToLower(arg))
if normedArg == "" {
continue
}
id, err := influxdb.IDFromString(normedArg)
if err != nil {
errs = append(errs, "arg must provide a valid 16 length ID; got: "+arg)
continue
}
ids = append(ids, *id)
}
if len(errs) > 0 {
return nil, errors.New(strings.Join(errs, "\n\t"))
}
return ids, nil
}
func writePkg(w io.Writer, pkgSVC pkger.SVC, outPath string, opts ...pkger.CreatePkgSetFn) error {
pkg, err := pkgSVC.CreatePkg(context.Background(), opts...)
if err != nil {
return err
}
buf, err := createPkgBuf(pkg, outPath)
if err != nil {
return err
}
if outPath == "" {
_, err := io.Copy(w, buf)
return err
}
return ioutil.WriteFile(outPath, buf.Bytes(), os.ModePerm)
}
func createPkgBuf(pkg *pkger.Pkg, outPath string) (*bytes.Buffer, error) {
var (
buf bytes.Buffer
enc interface {
Encode(interface{}) error
}
)
switch ext := filepath.Ext(outPath); ext {
case ".json":
jsonEnc := json.NewEncoder(&buf)
jsonEnc.SetIndent("", "\t")
enc = jsonEnc
default:
enc = yaml.NewEncoder(&buf)
}
if err := enc.Encode(pkg); err != nil {
return nil, err
}
return &buf, nil
}
func newPkgerSVC(cliReqOpts httpClientOpts) (pkger.SVC, error) {
return pkger.NewService(
pkger.WithBucketSVC(&ihttp.BucketService{
Addr: cliReqOpts.addr,
Token: cliReqOpts.token,
InsecureSkipVerify: cliReqOpts.skipVerify,
}),
pkger.WithDashboardSVC(&ihttp.DashboardService{
Addr: cliReqOpts.addr,
Token: cliReqOpts.token,
InsecureSkipVerify: cliReqOpts.skipVerify,
}),
pkger.WithLabelSVC(&ihttp.LabelService{
Addr: cliReqOpts.addr,
Token: cliReqOpts.token,
InsecureSkipVerify: cliReqOpts.skipVerify,
}),
pkger.WithVariableSVC(&ihttp.VariableService{
Addr: cliReqOpts.addr,
Token: cliReqOpts.token,
InsecureSkipVerify: cliReqOpts.skipVerify,
}),
), nil
}
func pkgFromStdIn(stdin *os.File) (*pkger.Pkg, error) {
b, err := ioutil.ReadAll(stdin)
if err != nil {
return nil, err
}
var enc pkger.Encoding
switch http.DetectContentType(b[0:512]) {
case "application/json":
enc = pkger.EncodingJSON
default:
enc = pkger.EncodingYAML
}
return pkger.Parse(enc, pkger.FromString(string(b)))
}
func pkgFromFile(path string) (*pkger.Pkg, error) {
var enc pkger.Encoding
switch ext := filepath.Ext(path); ext {
case ".yaml", ".yml":
enc = pkger.EncodingYAML
case ".json":
enc = pkger.EncodingJSON
default:
return nil, errors.New("file provided must be one of yaml/yml/json extension but got: " + ext)
}
return pkger.Parse(enc, pkger.FromFile(path))
}
func printPkgDiff(hasColor, hasTableBorders bool, diff pkger.Diff) {
red := color.New(color.FgRed).SprintfFunc()
green := color.New(color.FgHiGreen, color.Bold).SprintfFunc()
strDiff := func(isNew bool, old, new string) string {
if isNew {
return green(new)
}
if old == new {
return new
}
return fmt.Sprintf("%s\n%s", red("%q", old), green("%q", new))
}
boolDiff := func(b bool) string {
bb := strconv.FormatBool(b)
if b {
return green(bb)
}
return bb
}
durDiff := func(isNew bool, oldDur, newDur time.Duration) string {
o := oldDur.String()
if oldDur == 0 {
o = "inf"
}
n := newDur.String()
if newDur == 0 {
n = "inf"
}
if isNew {
return green(n)
}
if oldDur == newDur {
return n
}
return fmt.Sprintf("%s\n%s", red(o), green(n))
}
tablePrintFn := tablePrinterGen(hasColor, hasTableBorders)
if labels := diff.Labels; len(labels) > 0 {
headers := []string{"New", "ID", "Name", "Color", "Description"}
tablePrintFn("LABELS", headers, len(labels), func(w *tablewriter.Table) {
for _, l := range labels {
w.Append([]string{
boolDiff(l.IsNew()),
l.ID.String(),
l.Name,
strDiff(l.IsNew(), l.OldColor, l.NewColor),
strDiff(l.IsNew(), l.OldDesc, l.NewDesc),
})
}
})
}
if bkts := diff.Buckets; len(bkts) > 0 {
headers := []string{"New", "ID", "Name", "Retention Period", "Description"}
tablePrintFn("BUCKETS", headers, len(bkts), func(w *tablewriter.Table) {
for _, b := range bkts {
w.Append([]string{
boolDiff(b.IsNew()),
b.ID.String(),
b.Name,
durDiff(b.IsNew(), b.OldRetention, b.NewRetention),
strDiff(b.IsNew(), b.OldDesc, b.NewDesc),
})
}
})
}
if dashes := diff.Dashboards; len(dashes) > 0 {
headers := []string{"New", "Name", "Description", "Num Charts"}
tablePrintFn("DASHBOARDS", headers, len(dashes), func(w *tablewriter.Table) {
for _, d := range dashes {
w.Append([]string{
boolDiff(true),
d.Name,
green(d.Desc),
green(strconv.Itoa(len(d.Charts))),
})
}
})
}
if vars := diff.Variables; len(vars) > 0 {
headers := []string{"New", "ID", "Name", "Description", "Arg Type", "Arg Values"}
tablePrintFn("VARIABLES", headers, len(vars), func(w *tablewriter.Table) {
for _, v := range vars {
var oldArgType string
if v.OldArgs != nil {
oldArgType = v.OldArgs.Type
}
var newArgType string
if v.NewArgs != nil {
newArgType = v.NewArgs.Type
}
w.Append([]string{
boolDiff(v.IsNew()),
v.ID.String(),
v.Name,
strDiff(v.IsNew(), v.OldDesc, v.NewDesc),
strDiff(v.IsNew(), oldArgType, newArgType),
strDiff(v.IsNew(), printVarArgs(v.OldArgs), printVarArgs(v.NewArgs)),
})
}
})
}
if len(diff.LabelMappings) > 0 {
headers := []string{"New", "Resource Type", "Resource Name", "Resource ID", "Label Name", "Label ID"}
tablePrintFn("LABEL MAPPINGS", headers, len(diff.LabelMappings), func(w *tablewriter.Table) {
for _, m := range diff.LabelMappings {
w.Append([]string{
boolDiff(m.IsNew),
string(m.ResType),
m.ResName,
m.ResID.String(),
m.LabelName,
m.LabelID.String(),
})
}
})
}
}
func printVarArgs(a *influxdb.VariableArguments) string {
if a == nil {
return "<nil>"
}
if a.Type == "map" {
b, err := json.Marshal(a.Values)
if err != nil {
return "{}"
}
return string(b)
}
if a.Type == "constant" {
vals, ok := a.Values.(influxdb.VariableConstantValues)
if !ok {
return "[]"
}
var out []string
for _, s := range vals {
out = append(out, fmt.Sprintf("%q", s))
}
return fmt.Sprintf("[%s]", strings.Join(out, " "))
}
if a.Type == "query" {
qVal, ok := a.Values.(influxdb.VariableQueryValues)
if !ok {
return ""
}
return fmt.Sprintf("language=%q query=%q", qVal.Language, qVal.Query)
}
return "unknown variable argument"
}
func printPkgSummary(hasColor, hasTableBorders bool, sum pkger.Summary) {
tablePrintFn := tablePrinterGen(hasColor, hasTableBorders)
if labels := sum.Labels; len(labels) > 0 {
headers := []string{"ID", "Name", "Description", "Color"}
tablePrintFn("LABELS", headers, len(labels), func(w *tablewriter.Table) {
for _, l := range labels {
w.Append([]string{
l.ID.String(),
l.Name,
l.Properties["description"],
l.Properties["color"],
})
}
})
}
if buckets := sum.Buckets; len(buckets) > 0 {
headers := []string{"ID", "Name", "Retention", "Description"}
tablePrintFn("BUCKETS", headers, len(buckets), func(w *tablewriter.Table) {
for _, bucket := range buckets {
w.Append([]string{
bucket.ID.String(),
bucket.Name,
formatDuration(bucket.RetentionPeriod),
bucket.Description,
})
}
})
}
if dashes := sum.Dashboards; len(dashes) > 0 {
headers := []string{"ID", "Name", "Description"}
tablePrintFn("DASHBOARDS", headers, len(dashes), func(w *tablewriter.Table) {
for _, d := range dashes {
w.Append([]string{
d.ID.String(),
d.Name,
d.Description,
})
}
})
}
if vars := sum.Variables; len(vars) > 0 {
headers := []string{"ID", "Name", "Description", "Arg Type", "Arg Values"}
tablePrintFn("VARIABLES", headers, len(vars), func(w *tablewriter.Table) {
for _, v := range vars {
args := v.Arguments
w.Append([]string{
v.ID.String(),
v.Name,
v.Description,
args.Type,
printVarArgs(args),
})
}
})
}
if mappings := sum.LabelMappings; len(mappings) > 0 {
headers := []string{"Resource Type", "Resource Name", "Resource ID", "Label Name", "Label ID"}
tablePrintFn("LABEL MAPPINGS", headers, len(mappings), func(w *tablewriter.Table) {
for _, m := range mappings {
w.Append([]string{
string(m.ResourceType),
m.ResourceName,
m.ResourceID.String(),
m.LabelName,
m.LabelID.String(),
})
}
})
}
}
func tablePrinterGen(hasColor, hasTableBorder bool) func(table string, headers []string, count int, appendFn func(w *tablewriter.Table)) {
return func(table string, headers []string, count int, appendFn func(w *tablewriter.Table)) {
tablePrinter(table, headers, count, hasColor, hasTableBorder, appendFn)
}
}
func tablePrinter(table string, headers []string, count int, hasColor, hasTableBorders bool, appendFn func(w *tablewriter.Table)) {
descrCol := -1
for i, h := range headers {
if strings.ToLower(h) == "description" {
descrCol = i
break
}
}
w := tablewriter.NewWriter(os.Stdout)
w.SetBorder(hasTableBorders)
w.SetRowLine(hasTableBorders)
var alignments []int
for range headers {
alignments = append(alignments, tablewriter.ALIGN_CENTER)
}
if descrCol != -1 {
w.SetColMinWidth(descrCol, 30)
alignments[descrCol] = tablewriter.ALIGN_LEFT
}
color.New(color.FgYellow, color.Bold).Fprintln(os.Stdout, strings.ToUpper(table))
w.SetHeader(headers)
w.SetColumnAlignment(alignments)
appendFn(w)
footers := make([]string, len(headers))
footers[len(footers)-2] = "TOTAL"
footers[len(footers)-1] = strconv.Itoa(count)
w.SetFooter(footers)
if hasColor {
var colors []tablewriter.Colors
for i := 0; i < len(headers); i++ {
colors = append(colors, tablewriter.Color(tablewriter.FgHiCyanColor))
}
w.SetHeaderColor(colors...)
colors[len(colors)-2] = tablewriter.Color(tablewriter.FgHiBlueColor)
colors[len(colors)-1] = tablewriter.Color(tablewriter.FgHiBlueColor)
w.SetFooterColor(colors...)
}
w.Render()
fmt.Fprintln(os.Stdout)
}
func formatDuration(d time.Duration) string {
if d == 0 {
return "inf"
}
return d.String()
}