From 3ca681b1dd4826f2a34287f1a0df55b994f9fef4 Mon Sep 17 00:00:00 2001 From: Daniel Moran Date: Fri, 2 Jul 2021 10:19:51 -0400 Subject: [PATCH] feat: implement `template` command (#169) --- api/api_templates.gen.go | 11 + .../overrides/paths/templates_apply.yml | 6 + .../schemas/TemplateSummaryError.yml | 1 + api/error.go | 44 ++++ api/model_template_summary_error.gen.go | 12 +- ...l_template_summary_variable_args_render.go | 53 ++++ clients/apply/apply.go | 242 +----------------- clients/template/template.go | 119 +++++++++ cmd/influx/apply.go | 11 +- cmd/influx/main.go | 1 + cmd/influx/template.go | 173 +++++++++++++ {clients/apply => pkg/template}/source.go | 88 ++++--- .../apply => pkg/template}/source_test.go | 64 ++--- pkg/template/summary_printer.go | 199 ++++++++++++++ 14 files changed, 707 insertions(+), 317 deletions(-) create mode 100644 api/model_template_summary_variable_args_render.go create mode 100644 clients/template/template.go create mode 100644 cmd/influx/template.go rename {clients/apply => pkg/template}/source.go (70%) rename {clients/apply => pkg/template}/source_test.go (86%) create mode 100644 pkg/template/summary_printer.go diff --git a/api/api_templates.gen.go b/api/api_templates.gen.go index bb42cf3..79785c4 100644 --- a/api/api_templates.gen.go +++ b/api/api_templates.gen.go @@ -184,6 +184,17 @@ func (a *TemplatesApiService) ApplyTemplateExecute(r ApiApplyTemplateRequest) (T body: localVarBody, error: _fmt.Sprintf("%s%s", errorPrefix, localVarHTTPResponse.Status), } + if localVarHTTPResponse.StatusCode == 422 { + var v TemplateSummary + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = _fmt.Sprintf("%s: %s", newErr.Error(), err.Error()) + return localVarReturnValue, newErr + } + v.SetMessage(_fmt.Sprintf("%s: %s", newErr.Error(), v.GetMessage())) + newErr.model = &v + return localVarReturnValue, newErr + } var v Error err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) if err != nil { diff --git a/api/contract/overrides/paths/templates_apply.yml b/api/contract/overrides/paths/templates_apply.yml index ae99288..18fead3 100644 --- a/api/contract/overrides/paths/templates_apply.yml +++ b/api/contract/overrides/paths/templates_apply.yml @@ -29,6 +29,12 @@ post: application/json: schema: $ref: "../schemas/TemplateSummary.yml" + "422": + description: Template failed validation + content: + application/json: + schema: + $ref: "../schemas/TemplateSummary.yml" default: description: Unexpected error content: diff --git a/api/contract/overrides/schemas/TemplateSummaryError.yml b/api/contract/overrides/schemas/TemplateSummaryError.yml index 3898e3e..5c985fd 100644 --- a/api/contract/overrides/schemas/TemplateSummaryError.yml +++ b/api/contract/overrides/schemas/TemplateSummaryError.yml @@ -12,4 +12,5 @@ properties: type: array items: type: integer + x-go-field-type: '[]*int' required: [kind, reason, fields, indexes] diff --git a/api/error.go b/api/error.go index 24a28e3..dc8ed94 100644 --- a/api/error.go +++ b/api/error.go @@ -89,3 +89,47 @@ func (o *LineProtocolLengthError) ErrorCode() ErrorCode { return ERRORCODE_INVALID } } + +func (o *TemplateSummary) Error() string { + if len(o.Errors) == 0 { + panic("error-less template summary used as an error!") + } + + var errMsg []string + seenErrs := map[string]struct{}{} + for _, e := range o.Errors { + fieldPairs := make([]string, 0, len(e.Fields)) + for i, idx := range e.Indexes { + field := e.Fields[i] + if idx == nil || *idx == -1 { + fieldPairs = append(fieldPairs, field) + continue + } + fieldPairs = append(fieldPairs, fmt.Sprintf("%s[%d]", field, *idx)) + } + msg := fmt.Sprintf("kind=%s field=%s reason=%q", e.Kind, strings.Join(fieldPairs, "."), e.Reason) + if _, ok := seenErrs[msg]; ok { + continue + } + seenErrs[msg] = struct{}{} + errMsg = append(errMsg, msg) + } + + return strings.Join(errMsg, "\n\t") +} + +func (o *TemplateSummary) ErrorCode() ErrorCode { + if len(o.Errors) == 0 { + panic("error-less template summary used as an error!") + } + + return ERRORCODE_UNPROCESSABLE_ENTITY +} + +func (o *TemplateSummary) SetMessage(string) { + // Placeholder to satisfy interface +} + +func (o TemplateSummary) GetMessage() string { + return o.Error() +} diff --git a/api/model_template_summary_error.gen.go b/api/model_template_summary_error.gen.go index 2c92642..cd15278 100644 --- a/api/model_template_summary_error.gen.go +++ b/api/model_template_summary_error.gen.go @@ -19,14 +19,14 @@ type TemplateSummaryError struct { Kind string `json:"kind" yaml:"kind"` Reason string `json:"reason" yaml:"reason"` Fields []string `json:"fields" yaml:"fields"` - Indexes []int32 `json:"indexes" yaml:"indexes"` + Indexes []*int `json:"indexes" yaml:"indexes"` } // NewTemplateSummaryError instantiates a new TemplateSummaryError object // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewTemplateSummaryError(kind string, reason string, fields []string, indexes []int32) *TemplateSummaryError { +func NewTemplateSummaryError(kind string, reason string, fields []string, indexes []*int) *TemplateSummaryError { this := TemplateSummaryError{} this.Kind = kind this.Reason = reason @@ -116,9 +116,9 @@ func (o *TemplateSummaryError) SetFields(v []string) { } // GetIndexes returns the Indexes field value -func (o *TemplateSummaryError) GetIndexes() []int32 { +func (o *TemplateSummaryError) GetIndexes() []*int { if o == nil { - var ret []int32 + var ret []*int return ret } @@ -127,7 +127,7 @@ func (o *TemplateSummaryError) GetIndexes() []int32 { // GetIndexesOk returns a tuple with the Indexes field value // and a boolean to check if the value has been set. -func (o *TemplateSummaryError) GetIndexesOk() (*[]int32, bool) { +func (o *TemplateSummaryError) GetIndexesOk() (*[]*int, bool) { if o == nil { return nil, false } @@ -135,7 +135,7 @@ func (o *TemplateSummaryError) GetIndexesOk() (*[]int32, bool) { } // SetIndexes sets field value -func (o *TemplateSummaryError) SetIndexes(v []int32) { +func (o *TemplateSummaryError) SetIndexes(v []*int) { o.Indexes = v } diff --git a/api/model_template_summary_variable_args_render.go b/api/model_template_summary_variable_args_render.go new file mode 100644 index 0000000..4399215 --- /dev/null +++ b/api/model_template_summary_variable_args_render.go @@ -0,0 +1,53 @@ +package api + +import ( + "encoding/json" + "fmt" + "log" + "strings" +) + +func (args *TemplateSummaryVariableArgs) Render() string { + if args == nil { + return "" + } + switch args.Type { + case "map": + b, err := json.Marshal(args.Values) + if err != nil { + log.Printf("WARN: failed to parse map-variable args: expected JSON, got %v\n", args.Values) + return "" + } + return string(b) + case "constant": + values, ok := args.Values.([]interface{}) + if !ok { + log.Printf("WARN: failed to parse constant-variable args: expected array, got %v\n", args.Values) + 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 { + log.Printf("WARN: failed to parse query-variable args: expected JSON object, got %v\n", args.Values) + return "" + } + qVal, ok := values["query"] + if !ok { + log.Printf("WARN: failed to parse query-variable args: no 'query' key in %v\n", values) + return "" + } + lVal, ok := values["language"] + if !ok { + log.Printf("WARN: failed to parse query-variable args: no 'language' key in %v\n", values) + return "" + } + return fmt.Sprintf("language=%q query=%q", lVal, qVal) + default: + } + return "" +} diff --git a/clients/apply/apply.go b/clients/apply/apply.go index 0062d17..d5676bf 100644 --- a/clients/apply/apply.go +++ b/clients/apply/apply.go @@ -2,12 +2,10 @@ package apply import ( "context" - "encoding/json" "errors" "fmt" "reflect" "strconv" - "strings" "time" "github.com/influxdata/influx-cli/v2/api" @@ -27,7 +25,7 @@ type Params struct { OrgName string StackId string - Sources []TemplateSource + Sources []template.Source Recursive bool Secrets map[string]string @@ -67,7 +65,7 @@ func (c Client) Apply(ctx context.Context, params *Params) error { orgID = orgs.GetOrgs()[0].GetId() } - templates, err := readTemplates(ctx, params.Sources) + templates, err := template.ReadSources(ctx, params.Sources) if err != nil { return err } @@ -158,20 +156,6 @@ func (c Client) Apply(ctx context.Context, params *Params) error { 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) @@ -401,7 +385,7 @@ func (c Client) printDiff(diff api.TemplateSummaryDiff, params *Params) error { if vf.Args != nil { argType = vf.Args.Type } - return []string{metaName, id, vf.Name, desc, argType, sprintVarArgs(vf.Args)} + return []string{metaName, id, vf.Name, desc, argType, vf.Args.Render()} } for _, v := range vars { var oldRow, newRow []string @@ -442,46 +426,6 @@ func (c Client) printDiff(diff api.TemplateSummaryDiff, params *Params) error { 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) { @@ -542,185 +486,9 @@ func (c Client) printSummary(summary api.TemplateSummaryResources, params *Param }) } - 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 err := template.PrintSummary(summary, c.StdIO, params.RenderTableColors, params.RenderTableBorders); err != nil { + return err } - - 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))) } diff --git a/clients/template/template.go b/clients/template/template.go new file mode 100644 index 0000000..6d8dc46 --- /dev/null +++ b/clients/template/template.go @@ -0,0 +1,119 @@ +package template + +import ( + "context" + "fmt" + + "github.com/influxdata/influx-cli/v2/api" + "github.com/influxdata/influx-cli/v2/clients" + "github.com/influxdata/influx-cli/v2/pkg/template" +) + +type Client struct { + clients.CLI + api.TemplatesApi + api.OrganizationsApi +} + +type SummarizeParams struct { + OrgId string + OrgName string + + Sources []template.Source + + RenderTableColors bool + RenderTableBorders bool +} + +func (c Client) Summarize(ctx context.Context, params *SummarizeParams) 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 := template.ReadSources(ctx, params.Sources) + if err != nil { + return err + } + + // Execute a dry-run to make the server summarize the template(s). + req := api.TemplateApply{ + DryRun: true, + OrgID: orgID, + Templates: templates, + } + res, err := c.ApplyTemplate(ctx).TemplateApply(req).Execute() + if err != nil { + return fmt.Errorf("failed to summarize template: %w", err) + } + + if c.PrintAsJSON { + return c.PrintJSON(res.Summary) + } + return template.PrintSummary(res.Summary, c.StdIO, params.RenderTableColors, params.RenderTableBorders) +} + +type ValidateParams struct { + OrgId string + OrgName string + + Sources []template.Source +} + +func (c Client) Validate(ctx context.Context, params *ValidateParams) 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 := template.ReadSources(ctx, params.Sources) + if err != nil { + return err + } + + // Execute a dry-run to make the server summarize the template(s). + req := api.TemplateApply{ + DryRun: true, + OrgID: orgID, + Templates: templates, + } + _, err = c.ApplyTemplate(ctx).TemplateApply(req).Execute() + if err == nil { + return nil + } + + if apiErr, ok := err.(api.GenericOpenAPIError); ok { + if summary, ok := apiErr.Model().(*api.TemplateSummary); ok { + return fmt.Errorf("template failed validation:\n\t%s", summary) + } + } + return fmt.Errorf("failed to validate template: %w", err) +} diff --git a/cmd/influx/apply.go b/cmd/influx/apply.go index 85630fa..3290f1a 100644 --- a/cmd/influx/apply.go +++ b/cmd/influx/apply.go @@ -9,6 +9,7 @@ import ( "github.com/influxdata/influx-cli/v2/clients/apply" "github.com/influxdata/influx-cli/v2/pkg/cli/middleware" + "github.com/influxdata/influx-cli/v2/pkg/template" "github.com/mattn/go-isatty" "github.com/urfave/cli" ) @@ -21,7 +22,7 @@ func newApplyCmd() cli.Command { inPaths cli.StringSlice inUrls cli.StringSlice recursive bool - encoding apply.TemplateEncoding + encoding template.Encoding noColor bool noTableBorders bool quiet bool @@ -192,9 +193,9 @@ https://github.com/influxdata/community-templates. 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)) + parsedParams.Sources = append(parsedParams.Sources, template.SourceFromURL(u, params.encoding)) } else { - fileSources, err := apply.SourcesFromPath(in, params.recursive, params.encoding) + fileSources, err := template.SourcesFromPath(in, params.recursive, params.encoding) if err != nil { return err } @@ -206,10 +207,10 @@ https://github.com/influxdata/community-templates. 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)) + parsedParams.Sources = append(parsedParams.Sources, template.SourceFromURL(u, params.encoding)) } if !isatty.IsTerminal(os.Stdin.Fd()) { - parsedParams.Sources = append(parsedParams.Sources, apply.SourceFromReader(os.Stdin, params.encoding)) + parsedParams.Sources = append(parsedParams.Sources, template.SourceFromReader(os.Stdin, params.encoding)) } // Parse env and secret values. diff --git a/cmd/influx/main.go b/cmd/influx/main.go index de10b03..b93e00f 100644 --- a/cmd/influx/main.go +++ b/cmd/influx/main.go @@ -52,6 +52,7 @@ var app = cli.App{ newAuthCommand(), newApplyCmd(), newStacksCmd(), + newTemplateCmd(), }, Before: withContext(), } diff --git a/cmd/influx/template.go b/cmd/influx/template.go new file mode 100644 index 0000000..9bcc21b --- /dev/null +++ b/cmd/influx/template.go @@ -0,0 +1,173 @@ +package main + +import ( + "fmt" + "log" + "net/url" + "strings" + + "github.com/influxdata/influx-cli/v2/clients/template" + "github.com/influxdata/influx-cli/v2/pkg/cli/middleware" + pkgtmpl "github.com/influxdata/influx-cli/v2/pkg/template" + "github.com/urfave/cli" +) + +type templateParams struct { + orgId string + orgName string + files cli.StringSlice + urls cli.StringSlice + recurse bool + encoding pkgtmpl.Encoding +} + +func templateFlags(params *templateParams) []cli.Flag { + return []cli.Flag{ + &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.StringSliceFlag{ + Name: "file, f", + Usage: "Path to template file; Supports file paths or (deprecated) HTTP(S) URLs", + TakesFile: true, + Value: ¶ms.files, + }, + &cli.StringSliceFlag{ + Name: "template-url, u", + Usage: "HTTP(S) URL to template file", + Value: ¶ms.urls, + }, + &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.recurse, + }, + &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, + }, + } +} + +func (params templateParams) parseSources() ([]pkgtmpl.Source, error) { + var deprecationShown bool + var sources []pkgtmpl.Source + for _, in := range params.files.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 nil, 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 + } + sources = append(sources, pkgtmpl.SourceFromURL(u, params.encoding)) + } else { + fileSources, err := pkgtmpl.SourcesFromPath(in, params.recurse, params.encoding) + if err != nil { + return nil, err + } + sources = append(sources, fileSources...) + } + } + for _, in := range params.urls.Value() { + u, err := url.Parse(in) + if err != nil { + return nil, fmt.Errorf("failed to parse input URL: %q: %w", in, err) + } + sources = append(sources, pkgtmpl.SourceFromURL(u, params.encoding)) + } + return sources, nil +} + +func newTemplateCmd() cli.Command { + var params struct { + templateParams + noColor bool + noTableBorders bool + } + return cli.Command{ + Name: "template", + Usage: "Summarize the provided template", + Subcommands: []cli.Command{ + newTemplateValidateCmd(), + }, + Flags: append( + append(commonFlags(), templateFlags(¶ms.templateParams)...), + &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, + }, + ), + Before: middleware.WithBeforeFns(withCli(), withApi(true)), + Action: func(ctx *cli.Context) error { + parsedParams := template.SummarizeParams{ + OrgId: params.orgId, + OrgName: params.orgName, + RenderTableColors: !params.noColor, + RenderTableBorders: !params.noTableBorders, + } + sources, err := params.parseSources() + if err != nil { + return err + } + parsedParams.Sources = sources + + api := getAPI(ctx) + client := template.Client{ + CLI: getCLI(ctx), + TemplatesApi: api.TemplatesApi, + OrganizationsApi: api.OrganizationsApi, + } + return client.Summarize(getContext(ctx), &parsedParams) + }, + } +} + +func newTemplateValidateCmd() cli.Command { + var params templateParams + return cli.Command{ + Name: "validate", + Usage: "Validate the provided template", + Flags: append(commonFlagsNoPrint(), templateFlags(¶ms)...), + Before: middleware.WithBeforeFns(withCli(), withApi(true)), + Action: func(ctx *cli.Context) error { + parsedParams := template.ValidateParams{ + OrgId: params.orgId, + OrgName: params.orgName, + } + sources, err := params.parseSources() + if err != nil { + return err + } + parsedParams.Sources = sources + + api := getAPI(ctx) + client := template.Client{ + CLI: getCLI(ctx), + TemplatesApi: api.TemplatesApi, + OrganizationsApi: api.OrganizationsApi, + } + return client.Validate(getContext(ctx), &parsedParams) + }, + } +} diff --git a/clients/apply/source.go b/pkg/template/source.go similarity index 70% rename from clients/apply/source.go rename to pkg/template/source.go index 7371cb2..7acf4ec 100644 --- a/clients/apply/source.go +++ b/pkg/template/source.go @@ -1,4 +1,4 @@ -package apply +package template import ( "context" @@ -18,75 +18,75 @@ import ( "gopkg.in/yaml.v3" ) -type TemplateEncoding int +type Encoding int const ( - TemplateEncodingUnknown TemplateEncoding = iota - TemplateEncodingJson - TemplateEncodingJsonnet - TemplateEncodingYaml + EncodingUnknown Encoding = iota + EncodingJson + EncodingJsonnet + EncodingYaml ) -func (e *TemplateEncoding) Set(v string) error { +func (e *Encoding) Set(v string) error { switch v { case "jsonnet": - *e = TemplateEncodingJsonnet + *e = EncodingJsonnet case "json": - *e = TemplateEncodingJson + *e = EncodingJson case "yml", "yaml": - *e = TemplateEncodingYaml + *e = EncodingYaml default: return fmt.Errorf("unknown inEncoding %q", v) } return nil } -func (e TemplateEncoding) String() string { +func (e Encoding) String() string { switch e { - case TemplateEncodingJsonnet: + case EncodingJsonnet: return "jsonnet" - case TemplateEncodingJson: + case EncodingJson: return "json" - case TemplateEncodingYaml: + case EncodingYaml: return "yaml" - case TemplateEncodingUnknown: + case EncodingUnknown: fallthrough default: return "unknown" } } -type TemplateSource struct { +type Source struct { Name string - Encoding TemplateEncoding + Encoding Encoding Open func(context.Context) (io.ReadCloser, error) } -func SourcesFromPath(path string, recur bool, encoding TemplateEncoding) ([]TemplateSource, error) { +func SourcesFromPath(path string, recur bool, encoding Encoding) ([]Source, error) { paths, err := findPaths(path, recur) if err != nil { return nil, fmt.Errorf("failed to find inputs at path %q: %w", path, err) } - sources := make([]TemplateSource, len(paths)) + sources := make([]Source, len(paths)) for i := range paths { path := paths[i] // Local var for the `Open` closure to capture. encoding := encoding - if encoding == TemplateEncodingUnknown { + if encoding == EncodingUnknown { switch filepath.Ext(path) { case ".jsonnet": - encoding = TemplateEncodingJsonnet + encoding = EncodingJsonnet case ".json": - encoding = TemplateEncodingJson + encoding = EncodingJson case ".yml": fallthrough case ".yaml": - encoding = TemplateEncodingYaml + encoding = EncodingYaml default: } } - sources[i] = TemplateSource{ + sources[i] = Source{ Name: path, Encoding: encoding, Open: func(context.Context) (io.ReadCloser, error) { @@ -127,24 +127,24 @@ func findPaths(path string, recur bool) ([]string, error) { return paths, nil } -func SourceFromURL(u *url.URL, encoding TemplateEncoding) TemplateSource { - if encoding == TemplateEncodingUnknown { +func SourceFromURL(u *url.URL, encoding Encoding) Source { + if encoding == EncodingUnknown { switch path.Ext(u.Path) { case ".jsonnet": - encoding = TemplateEncodingJsonnet + encoding = EncodingJsonnet case ".json": - encoding = TemplateEncodingJson + encoding = EncodingJson case ".yml": fallthrough case ".yaml": - encoding = TemplateEncodingYaml + encoding = EncodingYaml default: } } normalized := github.NormalizeURLToContent(u, ".yaml", ".yml", ".jsonnet", ".json").String() - return TemplateSource{ + return Source{ Name: normalized, Encoding: encoding, Open: func(ctx context.Context) (io.ReadCloser, error) { @@ -170,8 +170,8 @@ func SourceFromURL(u *url.URL, encoding TemplateEncoding) TemplateSource { } } -func SourceFromReader(r io.Reader, encoding TemplateEncoding) TemplateSource { - return TemplateSource{ +func SourceFromReader(r io.Reader, encoding Encoding) Source { + return Source{ Name: "byte stream", Encoding: encoding, Open: func(context.Context) (io.ReadCloser, error) { @@ -180,7 +180,21 @@ func SourceFromReader(r io.Reader, encoding TemplateEncoding) TemplateSource { } } -func (s TemplateSource) Read(ctx context.Context) (api.TemplateApplyTemplate, error) { +func ReadSources(ctx context.Context, sources []Source) ([]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 (s Source) Read(ctx context.Context) (api.TemplateApplyTemplate, error) { var entries []api.TemplateEntry if err := func() error { in, err := s.Open(ctx) @@ -190,13 +204,13 @@ func (s TemplateSource) Read(ctx context.Context) (api.TemplateApplyTemplate, er defer in.Close() switch s.Encoding { - case TemplateEncodingJsonnet: + case EncodingJsonnet: err = jsonnet.NewDecoder(in).Decode(&entries) - case TemplateEncodingJson: + case EncodingJson: err = json.NewDecoder(in).Decode(&entries) - case TemplateEncodingUnknown: + case EncodingUnknown: fallthrough // Assume YAML if we can't make a better guess - case TemplateEncodingYaml: + case EncodingYaml: dec := yaml.NewDecoder(in) for { var e api.TemplateEntry diff --git a/clients/apply/source_test.go b/pkg/template/source_test.go similarity index 86% rename from clients/apply/source_test.go rename to pkg/template/source_test.go index 57404e7..5f81a9f 100644 --- a/clients/apply/source_test.go +++ b/pkg/template/source_test.go @@ -1,4 +1,4 @@ -package apply_test +package template_test import ( "context" @@ -13,7 +13,7 @@ import ( "testing" "github.com/influxdata/influx-cli/v2/api" - "github.com/influxdata/influx-cli/v2/clients/apply" + "github.com/influxdata/influx-cli/v2/pkg/template" "github.com/stretchr/testify/require" ) @@ -22,14 +22,14 @@ func TestSourcesFromPath(t *testing.T) { type contents struct { name string - encoding apply.TemplateEncoding + encoding template.Encoding contents string } testCases := []struct { name string setup func(t *testing.T, rootDir string) inPath func(rootDir string) string - inEncoding apply.TemplateEncoding + inEncoding template.Encoding recursive bool expected func(rootDir string) []contents }{ @@ -44,7 +44,7 @@ func TestSourcesFromPath(t *testing.T) { expected: func(string) []contents { return []contents{{ name: "foo.json", - encoding: apply.TemplateEncodingJson, + encoding: template.EncodingJson, contents: "foo", }} }, @@ -60,7 +60,7 @@ func TestSourcesFromPath(t *testing.T) { expected: func(string) []contents { return []contents{{ name: "foo.yaml", - encoding: apply.TemplateEncodingYaml, + encoding: template.EncodingYaml, contents: "foo", }} }, @@ -76,7 +76,7 @@ func TestSourcesFromPath(t *testing.T) { expected: func(string) []contents { return []contents{{ name: "foo.yml", - encoding: apply.TemplateEncodingYaml, + encoding: template.EncodingYaml, contents: "foo", }} }, @@ -92,7 +92,7 @@ func TestSourcesFromPath(t *testing.T) { expected: func(string) []contents { return []contents{{ name: "foo.jsonnet", - encoding: apply.TemplateEncodingJsonnet, + encoding: template.EncodingJsonnet, contents: "foo", }} }, @@ -102,14 +102,14 @@ func TestSourcesFromPath(t *testing.T) { setup: func(t *testing.T, rootDir string) { require.NoError(t, os.WriteFile(filepath.Join(rootDir, "foo"), []byte("foo"), os.ModePerm)) }, - inEncoding: apply.TemplateEncodingJson, + inEncoding: template.EncodingJson, inPath: func(rootDir string) string { return filepath.Join(rootDir, "foo") }, expected: func(string) []contents { return []contents{{ name: "foo", - encoding: apply.TemplateEncodingJson, + encoding: template.EncodingJson, contents: "foo", }} }, @@ -131,12 +131,12 @@ func TestSourcesFromPath(t *testing.T) { { name: filepath.Join(rootDir, "foo.json"), contents: "foo.json", - encoding: apply.TemplateEncodingJson, + encoding: template.EncodingJson, }, { name: filepath.Join(rootDir, "foo.yml"), contents: "foo.yml", - encoding: apply.TemplateEncodingYaml, + encoding: template.EncodingYaml, }, } }, @@ -159,22 +159,22 @@ func TestSourcesFromPath(t *testing.T) { { name: filepath.Join(rootDir, "foo.json"), contents: "foo.json", - encoding: apply.TemplateEncodingJson, + encoding: template.EncodingJson, }, { name: filepath.Join(rootDir, "foo.yml"), contents: "foo.yml", - encoding: apply.TemplateEncodingYaml, + encoding: template.EncodingYaml, }, { name: filepath.Join(rootDir, "bar", "foo.yaml"), contents: "foo.yaml", - encoding: apply.TemplateEncodingYaml, + encoding: template.EncodingYaml, }, { name: filepath.Join(rootDir, "bar", "foo.jsonnet"), contents: "foo.jsonnet", - encoding: apply.TemplateEncodingJsonnet, + encoding: template.EncodingJsonnet, }, } }, @@ -191,7 +191,7 @@ func TestSourcesFromPath(t *testing.T) { defer os.RemoveAll(tmp) tc.setup(t, tmp) - sources, err := apply.SourcesFromPath(tc.inPath(tmp), tc.recursive, tc.inEncoding) + sources, err := template.SourcesFromPath(tc.inPath(tmp), tc.recursive, tc.inEncoding) require.NoError(t, err) expected := tc.expected(tmp) require.Len(t, sources, len(expected)) @@ -225,8 +225,8 @@ func TestSourceFromURL(t *testing.T) { testCases := []struct { name string filename string - inEncoding apply.TemplateEncoding - expectEncoding apply.TemplateEncoding + inEncoding template.Encoding + expectEncoding template.Encoding resStatus int resBody string expectErr bool @@ -234,43 +234,43 @@ func TestSourceFromURL(t *testing.T) { { name: "JSON file", filename: "foo.json", - expectEncoding: apply.TemplateEncodingJson, + expectEncoding: template.EncodingJson, resStatus: 200, resBody: "Foo bar", }, { name: "YAML file", filename: "foo.yaml", - expectEncoding: apply.TemplateEncodingYaml, + expectEncoding: template.EncodingYaml, resStatus: 200, resBody: "Foo bar", }, { name: "YML file", filename: "foo.yml", - expectEncoding: apply.TemplateEncodingYaml, + expectEncoding: template.EncodingYaml, resStatus: 200, resBody: "Foo bar", }, { name: "JSONNET file", filename: "foo.jsonnet", - expectEncoding: apply.TemplateEncodingJsonnet, + expectEncoding: template.EncodingJsonnet, resStatus: 200, resBody: "Foo bar", }, { name: "explicit encoding", filename: "foo", - inEncoding: apply.TemplateEncodingJson, - expectEncoding: apply.TemplateEncodingJson, + inEncoding: template.EncodingJson, + expectEncoding: template.EncodingJson, resStatus: 200, resBody: "Foo bar", }, { name: "err response", filename: "foo.json", - expectEncoding: apply.TemplateEncodingJson, + expectEncoding: template.EncodingJson, resStatus: 403, resBody: "OH NO", expectErr: true, @@ -292,7 +292,7 @@ func TestSourceFromURL(t *testing.T) { require.NoError(t, err) u.Path = tc.filename - source := apply.SourceFromURL(u, tc.inEncoding) + source := template.SourceFromURL(u, tc.inEncoding) in, err := source.Open(context.Background()) if tc.expectErr { require.Error(t, err) @@ -398,22 +398,22 @@ spec: testCases := []struct { name string - encoding apply.TemplateEncoding + encoding template.Encoding data string }{ { name: "JSON", - encoding: apply.TemplateEncodingJson, + encoding: template.EncodingJson, data: jsonTemplate, }, { name: "YAML", - encoding: apply.TemplateEncodingYaml, + encoding: template.EncodingYaml, data: yamlTemplate, }, { name: "JSONNET", - encoding: apply.TemplateEncodingJsonnet, + encoding: template.EncodingJsonnet, data: jsonnetTemplate, }, } @@ -423,7 +423,7 @@ spec: t.Run(tc.name, func(t *testing.T) { t.Parallel() - source := apply.SourceFromReader(strings.NewReader(tc.data), tc.encoding) + source := template.SourceFromReader(strings.NewReader(tc.data), tc.encoding) tmpl, err := source.Read(context.Background()) require.NoError(t, err) expected := api.TemplateApplyTemplate{ diff --git a/pkg/template/summary_printer.go b/pkg/template/summary_printer.go new file mode 100644 index 0000000..fad0510 --- /dev/null +++ b/pkg/template/summary_printer.go @@ -0,0 +1,199 @@ +package template + +import ( + "fmt" + "io" + "time" + + "github.com/influxdata/influx-cli/v2/api" + "github.com/influxdata/influx-cli/v2/pkg/influxid" +) + +// PrintSummary renders high-level info about a template as a table for display on the console. +// +// NOTE: The implementation here is very "static" in that it's hard-coded to look for specific +// resource-kinds and fields within those kinds. If the API changes to add more kinds / more fields, +// this function won't automatically pick them up & print them. It'd be nice to rework this to be +// less opinionated / more resilient to extension in the future... +func PrintSummary(summary api.TemplateSummaryResources, out io.Writer, useColor bool, useBorders bool) error { + newPrinter := func(title string, headers []string) *TablePrinter { + return NewTablePrinter(out, useColor, useBorders). + 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() + _, _ = out.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() + _, _ = out.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() + _, _ = out.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() + _, _ = out.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() + _, _ = out.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() + _, _ = out.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() + _, _ = out.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() + _, _ = out.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, v.Arguments.Render()}) + } + printer.Render() + _, _ = out.Write([]byte("\n")) + } + + if mappings := summary.LabelMappings; len(mappings) > 0 { + printer := NewTablePrinter(out, useColor, useBorders). + 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() + _, _ = out.Write([]byte("\n")) + } + + if secrets := summary.MissingSecrets; len(secrets) > 0 { + printer := NewTablePrinter(out, useColor, useBorders). + Title("MISSING SECRETS"). + SetHeaders("Secret Key") + for _, sk := range secrets { + printer.Append([]string{sk}) + } + printer.Render() + _, _ = out.Write([]byte("\n")) + } + + return nil +}