From a3f36a98213ed33d027cfc33ab80e88207af3f3c Mon Sep 17 00:00:00 2001 From: Daniel Moran Date: Wed, 30 Jun 2021 14:47:23 -0400 Subject: [PATCH] feat: port `apply` command from `influxdb` (#160) --- clients/apply/apply.go | 729 +++++++++++++++++++++++++++++++++++ clients/apply/source.go | 9 +- clients/apply/source_test.go | 13 +- cmd/influx/apply.go | 273 +++++++++++++ cmd/influx/main.go | 1 + internal/mock/stdio.gen.go | 14 + pkg/stdio/console.go | 5 + pkg/stdio/stdio.go | 1 + 8 files changed, 1037 insertions(+), 8 deletions(-) create mode 100644 clients/apply/apply.go create mode 100644 cmd/influx/apply.go diff --git a/clients/apply/apply.go b/clients/apply/apply.go new file mode 100644 index 0000000..0062d17 --- /dev/null +++ b/clients/apply/apply.go @@ -0,0 +1,729 @@ +package apply + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + "github.com/influxdata/influx-cli/v2/api" + "github.com/influxdata/influx-cli/v2/clients" + "github.com/influxdata/influx-cli/v2/pkg/influxid" + "github.com/influxdata/influx-cli/v2/pkg/template" +) + +type Client struct { + clients.CLI + api.TemplatesApi + api.OrganizationsApi +} + +type Params struct { + OrgId string + OrgName string + + StackId string + Sources []TemplateSource + Recursive bool + + Secrets map[string]string + EnvVars map[string]string + + Filters []ResourceFilter + + Force bool + OverwriteConflicts bool + Quiet bool + RenderTableColors bool + RenderTableBorders bool +} + +type ResourceFilter struct { + Kind string + Name *string +} + +func (c Client) Apply(ctx context.Context, params *Params) error { + if params.OrgId == "" && params.OrgName == "" && c.ActiveConfig.Org == "" { + return clients.ErrMustSpecifyOrg + } + orgID := params.OrgId + if orgID == "" { + orgName := params.OrgName + if orgName == "" { + orgName = c.ActiveConfig.Org + } + orgs, err := c.GetOrgs(ctx).Org(orgName).Execute() + if err != nil { + return fmt.Errorf("failed to get ID for org %q: %w", orgName, err) + } + if len(orgs.GetOrgs()) == 0 { + return fmt.Errorf("no orgs found with name %q: %w", orgName, err) + } + orgID = orgs.GetOrgs()[0].GetId() + } + + templates, err := readTemplates(ctx, params.Sources) + if err != nil { + return err + } + + req := api.TemplateApply{ + DryRun: true, + OrgID: orgID, + Templates: templates, + EnvRefs: params.EnvVars, + Secrets: params.Secrets, + Actions: make([]api.TemplateApplyAction, len(params.Filters)), + } + if params.StackId != "" { + req.StackID = ¶ms.StackId + } + for i, f := range params.Filters { + req.Actions[i].Action = api.TEMPLATEAPPLYACTIONKIND_SKIP_KIND + if f.Name != nil { + req.Actions[i].Action = api.TEMPLATEAPPLYACTIONKIND_SKIP_RESOURCE + } + req.Actions[i].Properties = api.TemplateApplyActionProperties{ + Kind: f.Kind, + ResourceTemplateName: f.Name, + } + } + + // Initial dry-run to make the server summarize the template & report any missing env/secret values. + res, err := c.ApplyTemplate(ctx).TemplateApply(req).Execute() + if err != nil { + return fmt.Errorf("failed to check template impact: %w", err) + } + + if c.StdIO.InputIsInteractive() && (len(res.Summary.MissingEnvRefs) > 0 || len(res.Summary.MissingSecrets) > 0) { + for _, e := range res.Summary.MissingEnvRefs { + val, err := c.StdIO.GetStringInput(fmt.Sprintf("Please provide environment reference value for key %s", e), "") + if err != nil { + return err + } + req.EnvRefs[e] = val + } + for _, s := range res.Summary.MissingSecrets { + val, err := c.StdIO.GetSecret(fmt.Sprintf("Please provide secret value for key %s (optional, press enter to skip)", s), 0) + if err != nil { + return err + } + if val != "" { + req.Secrets[s] = val + } + } + + // 2nd dry-run to see the diff after resolving all env/secret values. + res, err = c.ApplyTemplate(ctx).TemplateApply(req).Execute() + if err != nil { + return fmt.Errorf("failed to check template impact: %w", err) + } + } + + if !params.Quiet { + if err := c.printDiff(res.Diff, params); err != nil { + return err + } + } + + if c.StdIO.InputIsInteractive() && !params.Force { + if confirmed := c.StdIO.GetConfirm("Confirm application of the above resources"); !confirmed { + return errors.New("aborted application of template") + } + } + + if !params.OverwriteConflicts && hasConflicts(res.Diff) { + return errors.New("template has conflicts with existing resources and cannot safely apply") + } + + // Flip the dry-run flag and apply the template. + req.DryRun = false + res, err = c.ApplyTemplate(ctx).TemplateApply(req).Execute() + if err != nil { + return fmt.Errorf("failed to apply template: %w", err) + } + params.StackId = res.StackID + + if !params.Quiet { + if err := c.printSummary(res.Summary, params); err != nil { + return err + } + } + + return nil +} + +func readTemplates(ctx context.Context, sources []TemplateSource) ([]api.TemplateApplyTemplate, error) { + templates := make([]api.TemplateApplyTemplate, 0, len(sources)) + for _, source := range sources { + tmpl, err := source.Read(ctx) + if err != nil { + return nil, err + } + // We always send the templates as JSON. + tmpl.ContentType = "json" + templates = append(templates, tmpl) + } + return templates, nil +} + +func (c Client) printDiff(diff api.TemplateSummaryDiff, params *Params) error { + if c.PrintAsJSON { + return c.PrintJSON(diff) + } + + newDiffPrinter := func(title string, headers []string) *template.DiffPrinter { + return template.NewDiffPrinter(c.StdIO, params.RenderTableColors, params.RenderTableBorders). + Title(title). + SetHeaders(append([]string{"Metadata Name", "ID", "Resource Name"}, headers...)...) + } + + if labels := diff.Labels; len(labels) > 0 { + printer := newDiffPrinter("Labels", []string{"Color", "Description"}) + buildRow := func(metaName string, id string, lf api.TemplateSummaryDiffLabelFields) []string { + var desc string + if lf.Description != nil { + desc = *lf.Description + } + return []string{metaName, id, lf.Name, lf.Color, desc} + } + for _, l := range labels { + var oldRow, newRow []string + hexId := influxid.ID(l.Id).String() + if l.Old != nil { + oldRow = buildRow(l.TemplateMetaName, hexId, *l.Old) + } + if l.New != nil { + newRow = buildRow(l.TemplateMetaName, hexId, *l.New) + } + printer.AppendDiff(oldRow, newRow) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if bkts := diff.Buckets; len(bkts) > 0 { + printer := newDiffPrinter("Buckets", []string{"Retention Period", "Description", "Schema Type", "Num Measurements"}) + buildRow := func(metaName string, id string, bf api.TemplateSummaryDiffBucketFields) []string { + var desc string + if bf.Description != nil { + desc = *bf.Description + } + var retention time.Duration + if len(bf.RetentionRules) > 0 { + retention = time.Duration(bf.RetentionRules[0].EverySeconds) * time.Second + } + schemaType := api.SCHEMATYPE_IMPLICIT + if bf.SchemaType != nil { + schemaType = *bf.SchemaType + } + return []string{metaName, id, bf.Name, retention.String(), desc, schemaType.String(), strconv.Itoa(len(bf.MeasurementSchemas))} + } + for _, b := range bkts { + var oldRow, newRow []string + hexId := influxid.ID(b.Id).String() + if b.Old != nil { + oldRow = buildRow(b.TemplateMetaName, hexId, *b.Old) + } + if b.New != nil { + newRow = buildRow(b.TemplateMetaName, hexId, *b.New) + } + printer.AppendDiff(oldRow, newRow) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if checks := diff.Checks; len(checks) > 0 { + printer := newDiffPrinter("Checks", []string{"Description"}) + buildRow := func(metaName string, id string, cf api.TemplateSummaryDiffCheckFields) []string { + var desc string + if cf.Description != nil { + desc = *cf.Description + } + return []string{metaName, id, cf.Name, desc} + } + for _, c := range checks { + var oldRow, newRow []string + hexId := influxid.ID(c.Id).String() + if c.Old != nil { + oldRow = buildRow(c.TemplateMetaName, hexId, *c.Old) + } + if c.New != nil { + newRow = buildRow(c.TemplateMetaName, hexId, *c.New) + } + printer.AppendDiff(oldRow, newRow) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if dashboards := diff.Dashboards; len(dashboards) > 0 { + printer := newDiffPrinter("Dashboards", []string{"Description", "Num Charts"}) + buildRow := func(metaName string, id string, df api.TemplateSummaryDiffDashboardFields) []string { + var desc string + if df.Description != nil { + desc = *df.Description + } + return []string{metaName, id, df.Name, desc, strconv.Itoa(len(df.Charts))} + } + for _, d := range dashboards { + var oldRow, newRow []string + hexId := influxid.ID(d.Id).String() + if d.Old != nil { + oldRow = buildRow(d.TemplateMetaName, hexId, *d.Old) + } + if d.New != nil { + newRow = buildRow(d.TemplateMetaName, hexId, *d.New) + } + printer.AppendDiff(oldRow, newRow) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if endpoints := diff.NotificationEndpoints; len(endpoints) > 0 { + printer := newDiffPrinter("Notification Endpoints", nil) + buildRow := func(metaName string, id string, nef api.TemplateSummaryDiffNotificationEndpointFields) []string { + return []string{metaName, id, nef.Name} + } + for _, e := range endpoints { + var oldRow, newRow []string + hexId := influxid.ID(e.Id).String() + if e.Old != nil { + oldRow = buildRow(e.TemplateMetaName, hexId, *e.Old) + } + if e.New != nil { + newRow = buildRow(e.TemplateMetaName, hexId, *e.New) + } + printer.AppendDiff(oldRow, newRow) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if rules := diff.NotificationRules; len(rules) > 0 { + printer := newDiffPrinter("Notification Rules", []string{"Description", "Every", "Offset", "Endpoint Name", "Endpoint ID", "Endpoint Type"}) + buildRow := func(metaName string, id string, nrf api.TemplateSummaryDiffNotificationRuleFields) []string { + var desc string + if nrf.Description != nil { + desc = *nrf.Description + } + eid := influxid.ID(nrf.EndpointID).String() + return []string{metaName, id, nrf.Name, desc, nrf.Every, nrf.Offset, nrf.EndpointName, eid, nrf.EndpointType} + } + for _, r := range rules { + var oldRow, newRow []string + hexId := influxid.ID(r.Id).String() + if r.Old != nil { + oldRow = buildRow(r.TemplateMetaName, hexId, *r.Old) + } + if r.New != nil { + newRow = buildRow(r.TemplateMetaName, hexId, *r.New) + } + printer.AppendDiff(oldRow, newRow) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if teles := diff.TelegrafConfigs; len(teles) > 0 { + printer := newDiffPrinter("Telegraf Configurations", []string{"Description"}) + buildRow := func(metaName string, id string, tc api.TemplateSummaryTelegrafConfig) []string { + var desc string + if tc.Description != nil { + desc = *tc.Description + } + return []string{metaName, id, tc.Name, desc} + } + for _, t := range teles { + var oldRow, newRow []string + hexId := influxid.ID(t.Id).String() + if t.Old != nil { + oldRow = buildRow(t.TemplateMetaName, hexId, *t.Old) + } + if t.New != nil { + newRow = buildRow(t.TemplateMetaName, hexId, *t.New) + } + printer.AppendDiff(oldRow, newRow) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if tasks := diff.Tasks; len(tasks) > 0 { + printer := newDiffPrinter("Tasks", []string{"Description", "Cycle"}) + buildRow := func(metaName string, id string, tf api.TemplateSummaryDiffTaskFields) []string { + var desc string + if tf.Description != nil { + desc = *tf.Description + } + var timing string + if tf.Cron != nil { + timing = *tf.Cron + } else { + offset := time.Duration(0).String() + if tf.Offset != nil { + offset = *tf.Offset + } + // If `cron` isn't set, `every` must be set + timing = fmt.Sprintf("every: %s offset: %s", *tf.Every, offset) + } + return []string{metaName, id, tf.Name, desc, timing} + } + for _, t := range tasks { + var oldRow, newRow []string + hexId := influxid.ID(t.Id).String() + if t.Old != nil { + oldRow = buildRow(t.TemplateMetaName, hexId, *t.Old) + } + if t.New != nil { + newRow = buildRow(t.TemplateMetaName, hexId, *t.New) + } + printer.AppendDiff(oldRow, newRow) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if vars := diff.Variables; len(vars) > 0 { + printer := newDiffPrinter("Variables", []string{"Description", "Arg Type", "Arg Values"}) + buildRow := func(metaName string, id string, vf api.TemplateSummaryDiffVariableFields) []string { + var desc, argType string + if vf.Description != nil { + desc = *vf.Description + } + if vf.Args != nil { + argType = vf.Args.Type + } + return []string{metaName, id, vf.Name, desc, argType, sprintVarArgs(vf.Args)} + } + for _, v := range vars { + var oldRow, newRow []string + hexId := influxid.ID(v.Id).String() + if v.Old != nil { + oldRow = buildRow(v.TemplateMetaName, hexId, *v.Old) + } + if v.New != nil { + newRow = buildRow(v.TemplateMetaName, hexId, *v.New) + } + printer.AppendDiff(oldRow, newRow) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if mappings := diff.LabelMappings; len(mappings) > 0 { + printer := template.NewDiffPrinter(c.StdIO, params.RenderTableColors, params.RenderTableBorders). + Title("Label Associations"). + SetHeaders("Resource Type", "Resource Meta Name", "Resource Name", "Resource ID", "Label Package Name", "Label Name", "Label ID") + + for _, m := range mappings { + resId := influxid.ID(m.ResourceID).String() + labelId := influxid.ID(m.LabelID).String() + row := []string{m.ResourceType, m.ResourceName, resId, m.LabelTemplateMetaName, m.LabelName, labelId} + switch m.StateStatus { + case "new": + printer.AppendDiff(nil, row) + case "remove": + printer.AppendDiff(row, nil) + default: + printer.AppendDiff(row, row) + } + } + printer.Render() + } + + return nil +} + +func sprintVarArgs(args *api.TemplateSummaryVariableArgs) string { + if args == nil { + return "" + } + switch args.Type { + case "map": + b, err := json.Marshal(args.Values) + if err != nil { + return "{}" + } + return string(b) + case "constant": + values, ok := args.Values.([]interface{}) + if !ok { + return "[]" + } + var out []string + for _, v := range values { + out = append(out, fmt.Sprintf("%q", v)) + } + return fmt.Sprintf("[%s]", strings.Join(out, " ")) + case "query": + values, ok := args.Values.(map[string]interface{}) + if !ok { + return "" + } + qVal, ok := values["query"] + if !ok { + return "" + } + lVal, ok := values["language"] + if !ok { + return "" + } + return fmt.Sprintf("language=%q query=%q", lVal, qVal) + default: + } + return "unknown variable argument" +} + +func hasConflicts(diff api.TemplateSummaryDiff) bool { + for _, l := range diff.Labels { + if l.StateStatus != "new" && l.Old != nil && l.New != nil && !reflect.DeepEqual(l.Old, l.New) { + return true + } + } + for _, b := range diff.Buckets { + if b.StateStatus != "new" && b.Old != nil && b.New != nil && !reflect.DeepEqual(b.Old, b.New) { + return true + } + } + for _, c := range diff.Checks { + if c.StateStatus != "new" && c.Old != nil && c.New != nil && !reflect.DeepEqual(c.Old, c.New) { + return true + } + } + for _, d := range diff.Dashboards { + if d.StateStatus != "new" && d.Old != nil && d.New != nil && !reflect.DeepEqual(d.Old, d.New) { + return true + } + } + for _, e := range diff.NotificationEndpoints { + if e.StateStatus != "new" && e.Old != nil && e.New != nil && !reflect.DeepEqual(e.Old, e.New) { + return true + } + } + for _, r := range diff.NotificationRules { + if r.StateStatus != "new" && r.Old != nil && r.New != nil && !reflect.DeepEqual(r.Old, r.New) { + return true + } + } + for _, t := range diff.TelegrafConfigs { + if t.StateStatus != "new" && t.Old != nil && t.New != nil && !reflect.DeepEqual(t.Old, t.New) { + return true + } + } + for _, t := range diff.Tasks { + if t.StateStatus != "new" && t.Old != nil && t.New != nil && !reflect.DeepEqual(t.Old, t.New) { + return true + } + } + for _, v := range diff.Variables { + if v.StateStatus != "new" && v.Old != nil && v.New != nil && !reflect.DeepEqual(v.Old, v.New) { + return true + } + } + return false +} + +func (c Client) printSummary(summary api.TemplateSummaryResources, params *Params) error { + if c.PrintAsJSON { + return c.PrintJSON(struct { + StackID string `json:"stackID"` + Summary api.TemplateSummaryResources + }{ + StackID: params.StackId, + Summary: summary, + }) + } + + newPrinter := func(title string, headers []string) *template.TablePrinter { + return template.NewTablePrinter(c.StdIO, params.RenderTableColors, params.RenderTableBorders). + Title(title). + SetHeaders(append([]string{"Package Name", "ID", "Resource Name"}, headers...)...) + } + + if labels := summary.Labels; len(labels) > 0 { + printer := newPrinter("LABELS", []string{"Description", "Color"}) + for _, l := range labels { + id := influxid.ID(l.Id).String() + var desc string + if l.Properties.Description != nil { + desc = *l.Properties.Description + } + printer.Append([]string{l.TemplateMetaName, id, l.Name, desc, l.Properties.Color}) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if buckets := summary.Buckets; len(buckets) > 0 { + printer := newPrinter("BUCKETS", []string{"Retention", "Description", "Schema Type"}) + for _, b := range buckets { + id := influxid.ID(b.Id).String() + var desc string + if b.Description != nil { + desc = *b.Description + } + rp := "inf" + if b.RetentionPeriod != 0 { + rp = time.Duration(b.RetentionPeriod).String() + } + schemaType := api.SCHEMATYPE_IMPLICIT + if b.SchemaType != nil { + schemaType = *b.SchemaType + } + printer.Append([]string{b.TemplateMetaName, id, b.Name, rp, desc, schemaType.String()}) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if checks := summary.Checks; len(checks) > 0 { + printer := newPrinter("CHECKS", []string{"Description"}) + for _, c := range checks { + id := influxid.ID(c.Id).String() + var desc string + if c.Description != nil { + desc = *c.Description + } + printer.Append([]string{c.TemplateMetaName, id, c.Name, desc}) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if dashboards := summary.Dashboards; len(dashboards) > 0 { + printer := newPrinter("DASHBOARDS", []string{"Description"}) + for _, d := range dashboards { + id := influxid.ID(d.Id).String() + var desc string + if d.Description != nil { + desc = *d.Description + } + printer.Append([]string{d.TemplateMetaName, id, d.Name, desc}) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if endpoints := summary.NotificationEndpoints; len(endpoints) > 0 { + printer := newPrinter("NOTIFICATION ENDPOINTS", []string{"Description", "Status"}) + for _, e := range endpoints { + id := influxid.ID(e.Id).String() + var desc string + if e.Description != nil { + desc = *e.Description + } + printer.Append([]string{e.TemplateMetaName, id, e.Name, desc, e.Status}) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if rules := summary.NotificationRules; len(rules) > 0 { + printer := newPrinter("NOTIFICATION RULES", []string{"Description", "Every", "Offset", "Endpoint Name", "Endpoint ID", "Endpoint Type"}) + for _, r := range rules { + id := influxid.ID(r.Id).String() + eid := influxid.ID(r.EndpointID).String() + var desc string + if r.Description != nil { + desc = *r.Description + } + printer.Append([]string{r.TemplateMetaName, id, r.Name, desc, r.Every, r.Offset, r.EndpointTemplateMetaName, eid, r.EndpointType}) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if tasks := summary.Tasks; len(tasks) > 0 { + printer := newPrinter("TASKS", []string{"Description", "Cycle"}) + for _, t := range tasks { + id := influxid.ID(t.Id).String() + var desc string + if t.Description != nil { + desc = *t.Description + } + var timing string + if t.Cron != nil { + timing = *t.Cron + } else { + offset := time.Duration(0).String() + if t.Offset != nil { + offset = *t.Offset + } + // If `cron` isn't set, `every` must be set + timing = fmt.Sprintf("every: %s offset: %s", *t.Every, offset) + } + printer.Append([]string{t.TemplateMetaName, id, t.Name, desc, timing}) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if teles := summary.TelegrafConfigs; len(teles) > 0 { + printer := newPrinter("TELEGRAF CONFIGS", []string{"Description"}) + for _, t := range teles { + var desc string + if t.TelegrafConfig.Description != nil { + desc = *t.TelegrafConfig.Description + } + printer.Append([]string{t.TemplateMetaName, t.TelegrafConfig.Id, t.TelegrafConfig.Name, desc}) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if vars := summary.Variables; len(vars) > 0 { + printer := newPrinter("VARIABLES", []string{"Description", "Arg Type", "Arg Values"}) + for _, v := range vars { + id := influxid.ID(v.Id).String() + var desc string + if v.Description != nil { + desc = *v.Description + } + var argType string + if v.Arguments != nil { + argType = v.Arguments.Type + } + printer.Append([]string{v.TemplateMetaName, id, v.Name, desc, argType, sprintVarArgs(v.Arguments)}) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if mappings := summary.LabelMappings; len(mappings) > 0 { + printer := template.NewTablePrinter(c.StdIO, params.RenderTableColors, params.RenderTableBorders). + Title("LABEL ASSOCIATIONS"). + SetHeaders("Resource Type", "Resource Name", "Resource ID", "Label Name", "Label ID") + for _, m := range mappings { + rid := influxid.ID(m.ResourceID).String() + lid := influxid.ID(m.LabelID).String() + printer.Append([]string{m.ResourceType, m.ResourceName, rid, m.LabelName, lid}) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if secrets := summary.MissingSecrets; len(secrets) > 0 { + printer := template.NewTablePrinter(c.StdIO, params.RenderTableColors, params.RenderTableBorders). + Title("MISSING SECRETS"). + SetHeaders("Secret Key") + for _, sk := range secrets { + printer.Append([]string{sk}) + } + printer.Render() + _, _ = c.StdIO.Write([]byte("\n")) + } + + if params.StackId != "" { + _, _ = c.StdIO.Write([]byte(fmt.Sprintf("Stack ID: %s\n", params.StackId))) + } + + return nil +} diff --git a/clients/apply/source.go b/clients/apply/source.go index 212324e..7371cb2 100644 --- a/clients/apply/source.go +++ b/clients/apply/source.go @@ -180,7 +180,7 @@ func SourceFromReader(r io.Reader, encoding TemplateEncoding) TemplateSource { } } -func (s TemplateSource) Read(ctx context.Context) ([]api.TemplateEntry, error) { +func (s TemplateSource) Read(ctx context.Context) (api.TemplateApplyTemplate, error) { var entries []api.TemplateEntry if err := func() error { in, err := s.Open(ctx) @@ -210,8 +210,11 @@ func (s TemplateSource) Read(ctx context.Context) ([]api.TemplateEntry, error) { } return err }(); err != nil { - return nil, fmt.Errorf("failed to read template(s) from %q: %w", s.Name, err) + return api.TemplateApplyTemplate{}, fmt.Errorf("failed to read template(s) from %q: %w", s.Name, err) } - return entries, nil + return api.TemplateApplyTemplate{ + Sources: []string{s.Name}, + Contents: entries, + }, nil } diff --git a/clients/apply/source_test.go b/clients/apply/source_test.go index a534dd7..57404e7 100644 --- a/clients/apply/source_test.go +++ b/clients/apply/source_test.go @@ -377,7 +377,7 @@ spec: "name": "test", "retentionRules": []interface{}{ map[string]interface{}{ - "type": "expire", + "type": "expire", }, }, }, @@ -389,7 +389,7 @@ spec: "name": "test2", "retentionRules": []interface{}{ map[string]interface{}{ - "type": "expire", + "type": "expire", }, }, }, @@ -424,10 +424,13 @@ spec: t.Parallel() source := apply.SourceFromReader(strings.NewReader(tc.data), tc.encoding) - tmpls, err := source.Read(context.Background()) + tmpl, err := source.Read(context.Background()) require.NoError(t, err) - - require.Equal(t, parsed, tmpls) + expected := api.TemplateApplyTemplate{ + Sources: []string{source.Name}, + Contents: parsed, + } + require.Equal(t, expected, tmpl) }) } } diff --git a/cmd/influx/apply.go b/cmd/influx/apply.go new file mode 100644 index 0000000..85630fa --- /dev/null +++ b/cmd/influx/apply.go @@ -0,0 +1,273 @@ +package main + +import ( + "fmt" + "log" + "net/url" + "os" + "strings" + + "github.com/influxdata/influx-cli/v2/clients/apply" + "github.com/influxdata/influx-cli/v2/pkg/cli/middleware" + "github.com/mattn/go-isatty" + "github.com/urfave/cli" +) + +func newApplyCmd() cli.Command { + var params struct { + orgId string + orgName string + stackId string + inPaths cli.StringSlice + inUrls cli.StringSlice + recursive bool + encoding apply.TemplateEncoding + noColor bool + noTableBorders bool + quiet bool + force string + secrets cli.StringSlice + envVars cli.StringSlice + filters cli.StringSlice + } + return cli.Command{ + Name: "apply", + Usage: "Apply a template to manage resources", + Description: `The apply command applies InfluxDB template(s). Use the command to create new +resources via a declarative template. The apply command can consume templates +via file(s), url(s), stdin, or any combination of the 3. Each run of the apply +command ensures that all templates applied are applied in unison as a transaction. +If any unexpected errors are discovered then all side effects are rolled back. + +Examples: + # Apply a template via a file + influx apply -f $PATH_TO_TEMPLATE/template.json + + # Apply a stack that has associated templates. In this example the stack has a remote + # template associated with it. + influx apply --stack-id $STACK_ID + + # Apply a template associated with a stack. Stacks make template application idempotent. + influx apply -f $PATH_TO_TEMPLATE/template.json --stack-id $STACK_ID + + # Apply multiple template files together (mix of yaml and json) + influx apply \ + -f $PATH_TO_TEMPLATE/template_1.json \ + -f $PATH_TO_TEMPLATE/template_2.yml + + # Apply a template from a url + influx apply -u https://raw.githubusercontent.com/influxdata/community-templates/master/docker/docker.yml + + # Apply a template from STDIN + cat $TEMPLATE.json | influx apply --encoding json + + # Applying a directory of templates, takes everything from provided directory + influx apply -f $PATH_TO_TEMPLATE_DIR + + # Applying a directory of templates, recursively descending into child directories + influx apply -R -f $PATH_TO_TEMPLATE_DIR + + # Applying directories from many sources, file and URL + influx apply -f $PATH_TO_TEMPLATE/template.yml -f $URL_TO_TEMPLATE + + # Applying a template with actions to skip resources applied. The + # following example skips all buckets and the dashboard whose + # metadata.name field matches the provided $DASHBOARD_TMPL_NAME. + # format for filters: + # --filter=kind=Bucket + # --filter=resource=Label:$Label_TMPL_NAME + influx apply \ + -f $PATH_TO_TEMPLATE/template.yml \ + --filter kind=Bucket \ + --filter resource=Dashboard:$DASHBOARD_TMPL_NAME + +For information about finding and using InfluxDB templates, see +https://docs.influxdata.com/influxdb/latest/reference/cli/influx/apply/. + +For more templates created by the community, see +https://github.com/influxdata/community-templates. +`, + Flags: append( + commonFlags(), + &cli.StringFlag{ + Name: "org-id", + Usage: "The ID of the organization", + EnvVar: "INFLUX_ORG_ID", + Destination: ¶ms.orgId, + }, + &cli.StringFlag{ + Name: "org, o", + Usage: "The name of the organization", + EnvVar: "INFLUX_ORG", + Destination: ¶ms.orgName, + }, + &cli.StringFlag{ + Name: "stack-id", + Usage: "Stack ID to associate with template application", + Destination: ¶ms.stackId, + }, + &cli.StringSliceFlag{ + Name: "file, f", + Usage: "Path to template file; Supports file paths or (deprecated) HTTP(S) URLs", + TakesFile: true, + Value: ¶ms.inPaths, + }, + &cli.StringSliceFlag{ + Name: "template-url, u", + Usage: "HTTP(S) URL to template file", + Value: ¶ms.inUrls, + }, + &cli.BoolFlag{ + Name: "recurse, R", + Usage: "Process the directory used in -f, --file recursively. Useful when you want to manage related templates organized within the same directory.", + Destination: ¶ms.recursive, + }, + &cli.GenericFlag{ + Name: "encoding, e", + Usage: "Encoding for the input stream. If a file is provided will gather encoding type from file extension. If extension provided will override.", + Value: ¶ms.encoding, + }, + &cli.BoolFlag{ + Name: "disable-color", + Usage: "Disable color in output", + Destination: ¶ms.noColor, + }, + &cli.BoolFlag{ + Name: "disable-table-borders", + Usage: "Disable table borders", + Destination: ¶ms.noTableBorders, + }, + &cli.BoolFlag{ + Name: "quiet, q", + Usage: "Disable output printing", + Destination: ¶ms.quiet, + }, + &cli.StringFlag{ + Name: "force", + Usage: "Set to 'true' to skip confirmation before applying changes. Set to 'conflict' to skip confirmation and overwrite existing resources", + Destination: ¶ms.force, + }, + &cli.StringSliceFlag{ + Name: "secret", + Usage: "Secrets to provide alongside the template; format --secret SECRET_KEY=SECRET_VALUE --secret SECRET_KEY_2=SECRET_VALUE_2", + Value: ¶ms.secrets, + }, + &cli.StringSliceFlag{ + Name: "env-ref", + Usage: "Environment references to provide alongside the template; format --env-ref REF_KEY=REF_VALUE --env-ref REF_KEY_2=REF_VALUE_2", + Value: ¶ms.envVars, + }, + &cli.StringSliceFlag{ + Name: "filter", + Usage: "Resources to skip when applying the template. Filter out by `kind` or by `resource`", + Value: ¶ms.filters, + }, + ), + Before: middleware.WithBeforeFns(withCli(), withApi(true)), + Action: func(ctx *cli.Context) error { + parsedParams := apply.Params{ + OrgId: params.orgId, + OrgName: params.orgName, + StackId: params.stackId, + Recursive: params.recursive, + Quiet: params.quiet, + RenderTableBorders: !params.noTableBorders, + RenderTableColors: !params.noColor, + Secrets: make(map[string]string, len(params.secrets.Value())), + EnvVars: make(map[string]string, len(params.envVars.Value())), + Filters: make([]apply.ResourceFilter, len(params.filters.Value())), + } + + // Collect all the sources the CLI needs to read templates from. + var deprecationShown bool + for _, in := range params.inPaths.Value() { + // Heuristic to guess what's a URL and what's a local file. + // TODO: Remove this once we stop supporting URLs in the --file arg. + u, err := url.Parse(in) + if err != nil { + return fmt.Errorf("failed to parse input path %q: %w", in, err) + } + if strings.HasPrefix(u.Scheme, "http") { + if !deprecationShown { + log.Println("WARN: Passing URLs via -f/--file is deprecated, please use -u/--template-url instead") + deprecationShown = true + } + parsedParams.Sources = append(parsedParams.Sources, apply.SourceFromURL(u, params.encoding)) + } else { + fileSources, err := apply.SourcesFromPath(in, params.recursive, params.encoding) + if err != nil { + return err + } + parsedParams.Sources = append(parsedParams.Sources, fileSources...) + } + } + for _, in := range params.inUrls.Value() { + u, err := url.Parse(in) + if err != nil { + return fmt.Errorf("failed to parse input URL %q: %w", in, err) + } + parsedParams.Sources = append(parsedParams.Sources, apply.SourceFromURL(u, params.encoding)) + } + if !isatty.IsTerminal(os.Stdin.Fd()) { + parsedParams.Sources = append(parsedParams.Sources, apply.SourceFromReader(os.Stdin, params.encoding)) + } + + // Parse env and secret values. + for _, e := range params.envVars.Value() { + pieces := strings.Split(e, "=") + if len(pieces) != 2 { + return fmt.Errorf("env-ref %q has invalid format, must be `REF_KEY=REF_VALUE`", e) + } + parsedParams.EnvVars[pieces[0]] = pieces[1] + } + for _, s := range params.secrets.Value() { + pieces := strings.Split(s, "=") + if len(pieces) != 2 { + return fmt.Errorf("secret %q has invalid format, must be `SECRET_KEY=SECRET_VALUE`", s) + } + parsedParams.Secrets[pieces[0]] = pieces[1] + } + + // Parse filters. + for i, f := range params.filters.Value() { + pieces := strings.Split(f, "=") + if len(pieces) != 2 { + return fmt.Errorf("filter %q has invalid format, expected `resource=KIND:NAME` or `kind=KIND`", f) + } + key, val := pieces[0], pieces[1] + switch strings.ToLower(key) { + case "kind": + parsedParams.Filters[i] = apply.ResourceFilter{Kind: val} + case "resource": + valPieces := strings.Split(val, ":") + if len(valPieces) != 2 { + return fmt.Errorf("resource filter %q has invalid format, expected `resource=KIND:NAME", val) + } + kind, name := valPieces[0], valPieces[1] + parsedParams.Filters[i] = apply.ResourceFilter{Kind: kind, Name: &name} + default: + return fmt.Errorf("invalid filter type %q, supported values are `resource` and `kind`", key) + } + } + + // Parse our strange way of passing 'force' + switch params.force { + case "conflict": + parsedParams.Force = true + parsedParams.OverwriteConflicts = true + case "true": + parsedParams.Force = true + default: + } + + api := getAPI(ctx) + client := apply.Client{ + CLI: getCLI(ctx), + TemplatesApi: api.TemplatesApi, + OrganizationsApi: api.OrganizationsApi, + } + + return client.Apply(getContext(ctx), &parsedParams) + }, + } +} diff --git a/cmd/influx/main.go b/cmd/influx/main.go index 4b6613c..4aba758 100644 --- a/cmd/influx/main.go +++ b/cmd/influx/main.go @@ -50,6 +50,7 @@ var app = cli.App{ newSecretCommand(), newV1SubCommand(), newAuthCommand(), + newApplyCmd(), }, Before: withContext(), } diff --git a/internal/mock/stdio.gen.go b/internal/mock/stdio.gen.go index e1f3cad..9b825c9 100644 --- a/internal/mock/stdio.gen.go +++ b/internal/mock/stdio.gen.go @@ -120,6 +120,20 @@ func (mr *MockStdIOMockRecorder) GetStringInput(arg0, arg1 interface{}) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStringInput", reflect.TypeOf((*MockStdIO)(nil).GetStringInput), arg0, arg1) } +// InputIsInteractive mocks base method. +func (m *MockStdIO) InputIsInteractive() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InputIsInteractive") + ret0, _ := ret[0].(bool) + return ret0 +} + +// InputIsInteractive indicates an expected call of InputIsInteractive. +func (mr *MockStdIOMockRecorder) InputIsInteractive() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InputIsInteractive", reflect.TypeOf((*MockStdIO)(nil).InputIsInteractive)) +} + // Write mocks base method. func (m *MockStdIO) Write(arg0 []byte) (int, error) { m.ctrl.T.Helper() diff --git a/pkg/stdio/console.go b/pkg/stdio/console.go index 5ecd717..7fc1670 100644 --- a/pkg/stdio/console.go +++ b/pkg/stdio/console.go @@ -7,6 +7,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" + "github.com/mattn/go-isatty" ) // terminalStdio interacts with the user via an interactive terminal. @@ -55,6 +56,10 @@ func (t *terminalStdio) Error(message string) error { return r.Error(&cfg, errors.New(message)) } +func (t *terminalStdio) InputIsInteractive() bool { + return isatty.IsTerminal(t.Stdin.Fd()) || isatty.IsCygwinTerminal(t.Stdin.Fd()) +} + // GetStringInput prompts the user for arbitrary input. func (t *terminalStdio) GetStringInput(prompt, defaultValue string) (input string, err error) { question := survey.Input{ diff --git a/pkg/stdio/stdio.go b/pkg/stdio/stdio.go index 38c104d..c937d9d 100644 --- a/pkg/stdio/stdio.go +++ b/pkg/stdio/stdio.go @@ -9,6 +9,7 @@ type StdIO interface { WriteErr(p []byte) (n int, err error) Banner(message string) error Error(message string) error + InputIsInteractive() bool GetStringInput(prompt, defaultValue string) (string, error) GetSecret(prompt string, minLen int) (string, error) GetPassword(prompt string) (string, error)