Files
warmbly/cmd/warmblyctl/status.go
T

272 lines
8.5 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/warmbly/warmbly/internal/app/instancecheck"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/version"
)
// errChecksFailed is how `make doctor` fails a script. The findings are already
// on stdout, so main exits non-zero without printing anything else.
var errChecksFailed = errors.New("an instance check reported an error")
// instanceStatus is the whole answer to "what state is this install in".
// --json exists so the Makefile can branch on it without parsing prose. Every
// key here is consumed by something: never rename or drop one, only append.
type instanceStatus struct {
Accounts int `json:"accounts"`
Claimed bool `json:"claimed"`
SetupRequired bool `json:"setup_required"`
AdminCount int `json:"admin_count"`
Admins []string `json:"admins"`
Registration string `json:"registration"`
RegistrationSource string `json:"registration_source"`
MailTransport string `json:"mail_transport"`
MailDelivers bool `json:"mail_delivers"`
MailTransportSource string `json:"mail_transport_source"`
AppURL string `json:"app_url"`
AppURLSource string `json:"app_url_source"`
NextSteps []string `json:"next_steps"`
Checks []instancecheck.Finding `json:"checks"`
Summary instancecheck.Summary `json:"summary"`
Version string `json:"version"`
Commit string `json:"commit,omitempty"`
}
func runStatus(ctx context.Context, args []string) error {
fs := newFlagSet("status")
asJSON := fs.Bool("json", false, "print the state as JSON instead of prose")
quiet := fs.Bool("quiet", false, "print only the checks, without the instance summary (ignored with --json)")
if err := fs.Parse(args); err != nil {
return err
}
if err := noExtraArgs(fs); err != nil {
return err
}
c, err := connect(ctx)
if err != nil {
return err
}
defer c.close()
state, err := collectStatus(ctx, c)
if err != nil {
return err
}
// --json always exits 0: `make claim` uses it to decide the backend is
// answering at all, and a script reads summary.error for the verdict.
if *asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(state)
}
if !*quiet {
printStatus(state)
fmt.Println()
}
printChecks(state.Checks, state.Summary, *quiet)
if state.Summary.Error > 0 {
return errChecksFailed
}
return nil
}
func collectStatus(ctx context.Context, c *conn) (*instanceStatus, error) {
accounts, err := c.users.CountUsers(ctx)
if err != nil {
return nil, fmt.Errorf("counting accounts: %w", err)
}
admins := []string{}
result, aerr := c.admins.ListAdmins(ctx, nil, 100)
if aerr != nil {
return nil, fmt.Errorf("listing platform admins: %w", aerr)
}
for _, a := range result.Data {
admins = append(admins, fmt.Sprintf("%s (%s)", a.Email, roleLabel(a.AdminPermissions)))
}
transport := config.MailTransport()
delivers := transport != config.MailTransportLog
registration := config.LoadAuthPolicy(delivers).Registration
state := &instanceStatus{
Accounts: accounts,
Claimed: accounts > 0,
SetupRequired: accounts == 0,
AdminCount: len(admins),
Admins: admins,
Registration: registration,
RegistrationSource: registrationSource(registration),
MailTransport: transport,
MailDelivers: delivers,
MailTransportSource: mailTransportSource(),
AppURL: config.AppBaseURL(),
AppURLSource: appURLSource(),
Version: version.String(),
Commit: version.ShortCommit(),
}
state.NextSteps = nextSteps(state)
state.Checks, state.Summary = runChecks(ctx, c)
return state, nil
}
func printStatus(s *instanceStatus) {
claimed := "no, so a setup link can still claim it"
if s.Claimed {
claimed = "yes, so no setup link is issued"
}
mail := fmt.Sprintf("%s (%s; %s)", s.MailTransport, mailEffect(s.MailTransport, s.MailDelivers), s.MailTransportSource)
build := s.Version
if s.Commit != "" {
build += " (" + s.Commit + ")"
}
fmt.Println("Instance")
fmt.Printf(" Version %s\n", build)
fmt.Printf(" Accounts %d\n", s.Accounts)
fmt.Printf(" Claimed %s\n", claimed)
fmt.Printf(" Platform admins %d\n", s.AdminCount)
fmt.Printf(" Registration %s (%s)\n", s.Registration, s.RegistrationSource)
fmt.Printf(" Mail transport %s\n", mail)
fmt.Printf(" App URL %s (%s)\n", s.AppURL, s.AppURLSource)
if len(s.Admins) > 0 {
fmt.Println("\nPlatform admins")
for _, a := range s.Admins {
fmt.Printf(" %s\n", a)
}
}
printSteps("How to get in", s.NextSteps)
}
// nextSteps is the part an operator actually reads: what to run, given this
// exact state. Every branch ends in a command.
func nextSteps(s *instanceStatus) []string {
appURL := s.AppURL
if !s.Claimed {
steps := []string{
"No accounts exist yet, so the first person to open a setup link becomes the owner.",
"Print one: warmblyctl setup-link",
"Or skip the link and create the owner directly:",
" warmblyctl user create --email you@example.com --admin",
"Unattended installs can set WARMBLY_BOOTSTRAP_EMAIL and WARMBLY_BOOTSTRAP_PASSWORD_HASH before the first start.",
}
if !s.MailDelivers {
steps = append(steps, "Mail does not leave this instance, so any emailed code is printed in the backend log instead.")
}
return steps
}
steps := []string{fmt.Sprintf("Sign in at %s", appURL)}
if s.AdminCount == 0 {
steps = append(steps,
"No platform admin exists, so the admin panel is closed to everyone. Promote an account:",
" warmblyctl user grant-admin --email you@example.com --role super",
)
}
steps = append(steps,
"Lost the password: warmblyctl user reset-password --email you@example.com",
"Lost the authenticator: warmblyctl user disable-2fa --email you@example.com",
"No account of your own: warmblyctl user create --email you@example.com --admin",
"Who is an admin: warmblyctl user list --admin",
)
switch s.Registration {
case config.RegistrationInviteOnly:
steps = append(steps, "Registration is invite_only, so the signup form only accepts someone holding an invitation. The commands above do not need one.")
case config.RegistrationClosed:
steps = append(steps, "Registration is closed (DISABLE_REGISTRATION=true), so the signup form refuses everyone, invitation or not. The commands above still work.")
}
if !s.MailDelivers {
steps = append(steps, "Mail does not leave this instance, so invitations and reset emails are printed in the backend log instead of being delivered.")
}
return steps
}
// registrationSource says whether the operator chose this mode or inherited it,
// which is the difference between "as configured" and "surprise".
func registrationSource(resolved string) string {
raw := strings.ToLower(strings.TrimSpace(os.Getenv("DISABLE_REGISTRATION")))
switch raw {
case config.RegistrationOpen, config.RegistrationInviteOnly, config.RegistrationClosed:
return "DISABLE_REGISTRATION=" + raw
case "":
return "DISABLE_REGISTRATION is unset, so the deployment default applies"
}
return fmt.Sprintf("DISABLE_REGISTRATION=%s is not a valid mode, so the deployment default applies", raw)
}
func mailTransportSource() string {
if v := strings.TrimSpace(os.Getenv("MAIL_TRANSPORT")); v != "" {
return "MAIL_TRANSPORT=" + v
}
if strings.TrimSpace(os.Getenv("SMTP_HOST")) != "" {
return "SMTP_HOST is set, so smtp is implied"
}
return "MAIL_TRANSPORT is unset, so ses is the fallback"
}
func mailEffect(transport string, delivers bool) string {
if !delivers {
return "written to the backend log, never delivered"
}
if transport == config.MailTransportSES {
return "delivered through AWS SES, which needs credentials and a verified identity"
}
return "delivered"
}
func appURLSource() string {
for _, key := range []string{"APP_URL", "FRONTEND_BASE_URL"} {
if strings.TrimSpace(os.Getenv(key)) != "" {
return key
}
}
return "neither APP_URL nor FRONTEND_BASE_URL is set, so the hosted default applies"
}
// roleLabel names a permission mask, falling back to the number so a custom
// grant is still legible.
func roleLabel(p models.AdminPermission) string {
if p == 0 {
return "not an admin"
}
for _, role := range []models.AdminRoleName{
models.AdminRoleSuper,
models.AdminRoleSupport,
models.AdminRoleOps,
models.AdminRoleAnalyst,
} {
if models.AdminRolePermissions[role] == p {
return fmt.Sprintf("%s, mask %d", role, uint32(p))
}
}
return fmt.Sprintf("custom, mask %d", uint32(p))
}