feat: port apply
command from influxdb
(#160)
This commit is contained in:
729
clients/apply/apply.go
Normal file
729
clients/apply/apply.go
Normal file
@ -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 "<nil>"
|
||||
}
|
||||
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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
273
cmd/influx/apply.go
Normal file
273
cmd/influx/apply.go
Normal file
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
@ -50,6 +50,7 @@ var app = cli.App{
|
||||
newSecretCommand(),
|
||||
newV1SubCommand(),
|
||||
newAuthCommand(),
|
||||
newApplyCmd(),
|
||||
},
|
||||
Before: withContext(),
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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{
|
||||
|
@ -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)
|
||||
|
Reference in New Issue
Block a user