From bbcb75598983fbba69ddb4798e2d7f3ad87696dd Mon Sep 17 00:00:00 2001 From: Dinushi Dhananjani Date: Thu, 2 Jul 2026 20:45:06 +0530 Subject: [PATCH 1/2] feat: extend init command with interactive wizard via survey --- go.mod | 7 +++ go.sum | 46 +++++++++++++++++ internal/cli/init.go | 120 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 152 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 04af3c6..2fdf742 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,16 @@ go 1.24 require gopkg.in/yaml.v3 v3.0.1 require ( + github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-colorable v0.1.2 // indirect + github.com/mattn/go-isatty v0.0.8 // indirect + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect go.etcd.io/bbolt v1.4.3 // indirect golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect + golang.org/x/text v0.4.0 // indirect ) diff --git a/go.sum b/go.sum index 783edee..37f1661 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,63 @@ +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/init.go b/internal/cli/init.go index dbf5bfc..aac0c87 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -1,49 +1,127 @@ package cli import ( - "bufio" "fmt" - "os" - "strings" - - "github.com/spf13/cobra" "github.com/burnless/burnless/internal/config" + + "github.com/AlecAivazis/survey/v2" + "github.com/spf13/cobra" ) // NewInitCmd creates the "burnless init" command. func NewInitCmd() *cobra.Command { return &cobra.Command{ - Use: "init", - Short: "Create a new sre.yaml for your service", - RunE: runInit, + Use: "init", + Short: "Create a new sre.yaml for your service", + SilenceUsage: true, + RunE: runInit, } } func runInit(cmd *cobra.Command, args []string) error { - reader := bufio.NewReader(os.Stdin) + fmt.Println() + fmt.Println(" Let's create your sre.yaml") + fmt.Println() + + // Step 1: service + team + var serviceName string + if err := survey.AskOne(&survey.Input{ + Message: "Service name:", + Help: "The name of the service this config applies to", + }, &serviceName, survey.WithValidator(survey.Required)); err != nil { + return err + } - serviceName := prompt(reader, "Service name") - team := prompt(reader, "Team") + var team string + if err := survey.AskOne(&survey.Input{ + Message: "Team:", + Help: "The team responsible for this service", + }, &team, survey.WithValidator(survey.Required)); err != nil { + return err + } + + // Step 2: SLO target + var sloTarget float64 + var sloTargetStr string + if err := survey.AskOne(&survey.Input{ + Message: "SLO availability target %:", + Default: "99.9", + Help: "e.g. 99.9 means 99.9% of requests must succeed", + }, &sloTargetStr, survey.WithValidator(survey.Required)); err != nil { + return err + } + if _, err := fmt.Sscanf(sloTargetStr, "%f", &sloTarget); err != nil { + return fmt.Errorf("invalid SLO target %q: enter a number like 99.9", sloTargetStr) + } + if sloTarget <= 0 || sloTarget > 100 { + return fmt.Errorf("SLO target must be between 0 and 100, got %.2f", sloTarget) + } + // Step 3: SLO window + var sloWindow string + if err := survey.AskOne(&survey.Select{ + Message: "SLO window:", + Options: []string{"7d", "30d", "90d"}, + Default: "30d", + Help: "The rolling time window for your SLO measurement", + }, &sloWindow); err != nil { + return err + } + + // Step 4: Prometheus metric + var metric string + if err := survey.AskOne(&survey.Input{ + Message: "Prometheus metric name:", + Default: "http_requests_total", + Help: "The metric used to calculate your SLO", + }, &metric, survey.WithValidator(survey.Required)); err != nil { + return err + } + + // Step 5: Slack channel + var slackChannel string + if err := survey.AskOne(&survey.Input{ + Message: "Slack channel for alerts:", + Default: "#sre-incidents", + Help: "Where burnless will send remediation notifications", + }, &slackChannel); err != nil { + return err + } + + // Step 6: on-call provider + var oncallProvider string + if err := survey.AskOne(&survey.Select{ + Message: "On-call provider:", + Options: []string{"pagerduty", "opsgenie", "none"}, + Default: "pagerduty", + Help: "The tool used for on-call routing", + }, &oncallProvider); err != nil { + return err + } + + // Generate and write sre.yaml cfg := config.GenerateDefault(serviceName, team) + cfg.SLOs[0].Target = sloTarget + cfg.SLOs[0].Window = sloWindow + cfg.SLOs[0].Indicator.Metric = metric + cfg.OnCall.NotifySlack = slackChannel + if oncallProvider != "none" { + cfg.OnCall.Provider = oncallProvider + } - // validate before writing — fail fast if something is wrong if err := cfg.Validate(); err != nil { return fmt.Errorf("generated config is invalid: %w", err) } - if err := config.WriteToFile(cfg, "sre.yaml"); err != nil { return fmt.Errorf("writing sre.yaml: %w", err) } - fmt.Println("✓ Created sre.yaml") + fmt.Println() + fmt.Printf(" Created sre.yaml for %s\n", serviceName) + fmt.Println(" Next steps:") + fmt.Println(" burnless validate -- check your config") + fmt.Println(" burnless diff -- preview what apply will create") + fmt.Println() return nil } - -// prompt prints a question and reads a single line of user input. -func prompt(reader *bufio.Reader, question string) string { - fmt.Printf("? %s: ", question) - answer, _ := reader.ReadString('\n') - return strings.TrimSpace(answer) -} From 1fde1ba4ecbda98dbb653a9e746ef35680771fc9 Mon Sep 17 00:00:00 2001 From: Dinushi Dhananjani Date: Fri, 3 Jul 2026 09:59:27 +0530 Subject: [PATCH 2/2] feat: implement burnless status command (closes #4) --- cmd/burnless/main.go | 1 + internal/cli/status.go | 96 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 internal/cli/status.go diff --git a/cmd/burnless/main.go b/cmd/burnless/main.go index 830542e..37efb27 100644 --- a/cmd/burnless/main.go +++ b/cmd/burnless/main.go @@ -30,6 +30,7 @@ func main() { rootCmd.AddCommand(cli.NewValidateCmd()) rootCmd.AddCommand(cli.NewToilCmd()) rootCmd.AddCommand(cli.NewDiffCmd()) + rootCmd.AddCommand(cli.NewStatusCmd()) if err := rootCmd.Execute(); err != nil { os.Exit(1) diff --git a/internal/cli/status.go b/internal/cli/status.go new file mode 100644 index 0000000..59bf948 --- /dev/null +++ b/internal/cli/status.go @@ -0,0 +1,96 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/burnless/burnless/internal/config" + + "github.com/spf13/cobra" +) + +// NewStatusCmd creates the "burnless status" command. +func NewStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status [file]", + Short: "Show SLO configuration and error budget status", + SilenceUsage: true, + Args: cobra.MaximumNArgs(1), + RunE: runStatus, + } + return cmd +} + +func runStatus(cmd *cobra.Command, args []string) error { + path := "sre.yaml" + if len(args) == 1 { + path = args[0] + } + + cfg, err := config.Load(path) + if err != nil { + return err + } + + divider := " " + strings.Repeat("-", 53) + + fmt.Println() + fmt.Printf(" Service: %s\n", cfg.Service) + fmt.Printf(" Team: %s\n", cfg.Team) + + // SLOs + fmt.Println() + fmt.Println(" SLOs configured:") + fmt.Println(divider) + for _, slo := range cfg.SLOs { + line := fmt.Sprintf(" %-20s target %.1f%% window %s", slo.Name, slo.Target, slo.Window) + if slo.ThresholdMS > 0 { + line += fmt.Sprintf(" threshold %dms", slo.ThresholdMS) + } + fmt.Println(line) + } + + // Error budget alerts + fmt.Println() + fmt.Println(" Error budget alerts:") + fmt.Println(divider) + for _, alert := range cfg.ErrorBudget.BurnRateAlerts { + remediate := "" + if alert.Remediate != "" { + remediate = fmt.Sprintf(" -> %s", alert.Remediate) + } + fmt.Printf(" %-10s %.1fx burn rate window %-4s%s\n", + alert.Severity, alert.Rate, alert.Window, remediate) + } + + // Runbooks + if len(cfg.Runbooks) > 0 { + fmt.Println() + fmt.Println(" Runbooks:") + fmt.Println(divider) + for name, rb := range cfg.Runbooks { + fmt.Printf(" %-14s mode: %-10s %d step(s)\n", + name, rb.Mode, len(rb.Steps)) + } + } + + // On-call + fmt.Println() + if cfg.OnCall.Provider != "" { + fmt.Printf(" On-call: %s (escalation: %d min)\n", + cfg.OnCall.Provider, cfg.OnCall.EscalationMinutes) + } + + // Dashboard + if cfg.Dashboards.Provider != "" { + fmt.Printf(" Dashboard: %s (auto-generate: %v)\n", + cfg.Dashboards.Provider, cfg.Dashboards.AutoGenerate) + } + + fmt.Println() + fmt.Println(" Run 'burnless apply' to deploy this config.") + fmt.Println(" Use '--live' flag with Prometheus URL for real-time burn rate (coming soon).") + fmt.Println() + + return nil +}