feat(nodectl): notice add/list/revoke 子命令(校验+审计+可选邮件)
This commit is contained in:
@@ -67,9 +67,16 @@ nodectl — elastic-node control plane CLI
|
||||
nodectl rotate-pool -pool=consumable|premium [-concurrency=1]
|
||||
nodectl providers [-pool=consumable|premium]
|
||||
nodectl bootstrap-token -node=UUID
|
||||
nodectl notice add -type=<important|feature|news|reward|version|promo>
|
||||
-title-zh=... -title-en=... [-body-zh=...] [-body-en=...]
|
||||
[-link=...] [-expires=YYYY-MM-DD] [-email]
|
||||
nodectl notice list [-all] [-limit=20]
|
||||
nodectl notice revoke <id>
|
||||
|
||||
Config via env: DB_DSN, REDIS_ADDR, REDIS_PASSWORD, PROVISION_CONTROL_PLANE_URL,
|
||||
PROVISION_CLOUD_INIT_TMPL, PROVISION_<VENDOR>_* credentials.`))
|
||||
PROVISION_CLOUD_INIT_TMPL, PROVISION_<VENDOR>_* credentials,
|
||||
SMTP_HOST/SMTP_PORT/SMTP_USERNAME/SMTP_PASSWORD/SMTP_FROM
|
||||
(notice add -email; unset = -email rejected with an error).`))
|
||||
}
|
||||
|
||||
func buildService(ctx context.Context) (*provision.Service, func(), error) {
|
||||
@@ -118,6 +125,8 @@ func run(ctx context.Context, cmd string, args []string) error {
|
||||
return cmdProviders(ctx, args)
|
||||
case "bootstrap-token":
|
||||
return cmdBootstrapToken(ctx, args)
|
||||
case "notice":
|
||||
return cmdNotice(ctx, args)
|
||||
case "-h", "--help", "help":
|
||||
usage()
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// notice.go implements `nodectl notice add/list/revoke` — the operator CLI
|
||||
// front-end for the notices package (system notifications: broadcast
|
||||
// announcements with an optional important-only email fallback).
|
||||
//
|
||||
// DB access reuses the same env-driven open pattern as buildService
|
||||
// (db.Open resolves driver via DB_DRIVER, defaulting to mysql, and reads
|
||||
// DB_DSN — see internal/db/db.go — so this also works against the sqlite
|
||||
// single-node deploy without any extra env here).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/db"
|
||||
"github.com/wangjia/pangolin/server/internal/notices"
|
||||
)
|
||||
|
||||
// noticeTypes mirrors notices.validNoticeTypes (unexported in that package,
|
||||
// kept in sync manually — see migrations/sqlite/000026_notices.up.sql CHECK).
|
||||
var noticeTypes = map[string]bool{
|
||||
"important": true,
|
||||
"feature": true,
|
||||
"news": true,
|
||||
"reward": true,
|
||||
"version": true,
|
||||
"promo": true,
|
||||
}
|
||||
|
||||
func cmdNotice(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("notice: subcommand required (add|list|revoke)")
|
||||
}
|
||||
sub := args[0]
|
||||
rest := args[1:]
|
||||
switch sub {
|
||||
case "add":
|
||||
return cmdNoticeAdd(ctx, rest)
|
||||
case "list":
|
||||
return cmdNoticeList(ctx, rest)
|
||||
case "revoke":
|
||||
return cmdNoticeRevoke(ctx, rest)
|
||||
default:
|
||||
return fmt.Errorf("notice: unknown subcommand %q (want add|list|revoke)", sub)
|
||||
}
|
||||
}
|
||||
|
||||
// parseNoticeAddFlags parses `nodectl notice add` flags into a
|
||||
// notices.PublishInput. Kept separate from cmdNoticeAdd so validation is
|
||||
// unit-testable without a DB: invalid type, missing titles, --email on a
|
||||
// non-important type, and malformed --expires all fail here, before any
|
||||
// Service/Store call.
|
||||
func parseNoticeAddFlags(args []string) (notices.PublishInput, error) {
|
||||
fs := flag.NewFlagSet("notice add", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
typ := fs.String("type", "", "notice type: important|feature|news|reward|version|promo (required)")
|
||||
titleZH := fs.String("title-zh", "", "Chinese title (required)")
|
||||
titleEN := fs.String("title-en", "", "English title (required)")
|
||||
bodyZH := fs.String("body-zh", "", "Chinese body (optional)")
|
||||
bodyEN := fs.String("body-en", "", "English body (optional)")
|
||||
link := fs.String("link", "", "deep link / URL (optional)")
|
||||
expires := fs.String("expires", "", "expiry date YYYY-MM-DD (expires at 23:59:59 UTC that day; optional)")
|
||||
email := fs.Bool("email", false, "also send an email fallback (type=important only)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return notices.PublishInput{}, fmt.Errorf("notice add: %w", err)
|
||||
}
|
||||
|
||||
if !noticeTypes[*typ] {
|
||||
return notices.PublishInput{}, fmt.Errorf("notice add: invalid -type %q (want important|feature|news|reward|version|promo)", *typ)
|
||||
}
|
||||
if strings.TrimSpace(*titleZH) == "" {
|
||||
return notices.PublishInput{}, fmt.Errorf("notice add: -title-zh is required")
|
||||
}
|
||||
if strings.TrimSpace(*titleEN) == "" {
|
||||
return notices.PublishInput{}, fmt.Errorf("notice add: -title-en is required")
|
||||
}
|
||||
if *email && *typ != "important" {
|
||||
return notices.PublishInput{}, fmt.Errorf("notice add: -email is only allowed for -type=important, got %q", *typ)
|
||||
}
|
||||
|
||||
in := notices.PublishInput{
|
||||
Type: *typ,
|
||||
TitleZH: *titleZH,
|
||||
TitleEN: *titleEN,
|
||||
BodyZH: *bodyZH,
|
||||
BodyEN: *bodyEN,
|
||||
Link: *link,
|
||||
Email: *email,
|
||||
}
|
||||
|
||||
if strings.TrimSpace(*expires) != "" {
|
||||
day, err := time.Parse("2006-01-02", *expires)
|
||||
if err != nil {
|
||||
return notices.PublishInput{}, fmt.Errorf("notice add: invalid -expires %q (want YYYY-MM-DD): %w", *expires, err)
|
||||
}
|
||||
exp := time.Date(day.Year(), day.Month(), day.Day(), 23, 59, 59, 0, time.UTC)
|
||||
in.ExpiresAt = &exp
|
||||
}
|
||||
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func cmdNoticeAdd(ctx context.Context, args []string) error {
|
||||
in, err := parseNoticeAddFlags(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, closeFn, err := openNoticeDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeFn()
|
||||
|
||||
st := notices.NewStore(conn)
|
||||
svc := notices.NewService(st, buildNoticeMailer(), conn)
|
||||
|
||||
id, err := svc.Publish(ctx, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("published notice id=%d type=%s\n", id, in.Type)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdNoticeList(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("notice list", flag.ExitOnError)
|
||||
all := fs.Bool("all", false, "include revoked/expired notices")
|
||||
limit := fs.Int("limit", 20, "max rows")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
conn, closeFn, err := openNoticeDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeFn()
|
||||
|
||||
st := notices.NewStore(conn)
|
||||
rows, err := st.ListAdmin(ctx, *all, *limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tTYPE\tTITLE_ZH\tPUBLISHED_AT\tSTATUS")
|
||||
for _, r := range rows {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", r.ID, r.Type, r.TitleZH, r.PublishedAt.Format(time.RFC3339), r.Status)
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func cmdNoticeRevoke(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("notice revoke", flag.ExitOnError)
|
||||
_ = fs.Parse(args)
|
||||
if fs.NArg() != 1 {
|
||||
return fmt.Errorf("notice revoke: exactly one positional argument <id> is required")
|
||||
}
|
||||
id, err := strconv.ParseInt(fs.Arg(0), 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notice revoke: invalid id %q: %w", fs.Arg(0), err)
|
||||
}
|
||||
|
||||
conn, closeFn, err := openNoticeDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeFn()
|
||||
|
||||
st := notices.NewStore(conn)
|
||||
svc := notices.NewService(st, buildNoticeMailer(), conn)
|
||||
if err := svc.RevokeByID(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("revoked notice id=%d\n", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// openNoticeDB opens the control-plane DB the same way buildService does:
|
||||
// db.Open(dsn) resolves the driver internally via DB_DRIVER (defaulting to
|
||||
// mysql; sqlite for single-node deploys) — see internal/db/db.go — so no
|
||||
// extra driver plumbing is needed here.
|
||||
func openNoticeDB() (*sql.DB, func(), error) {
|
||||
dsn := os.Getenv("DB_DSN")
|
||||
if dsn == "" {
|
||||
return nil, nil, fmt.Errorf("DB_DSN is required")
|
||||
}
|
||||
conn, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, func() { conn.Close() }, nil
|
||||
}
|
||||
|
||||
// buildNoticeMailer constructs a notices.EmailSender from the same SMTP_*
|
||||
// env vars cmd/server/main.go uses to wire auth.SMTPMailer (SMTP_HOST,
|
||||
// SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM). auth.SMTPMailer
|
||||
// itself isn't reused because it only implements auth.Mailer's
|
||||
// SendCode/SendAlreadyRegistered, not notices.EmailSender's generic
|
||||
// Send(to, subject, body) — so this is a small parallel net/smtp sender.
|
||||
// Returns a nil (untyped) notices.EmailSender when SMTP_HOST is unset;
|
||||
// Service.Publish then explicitly rejects -email requests ("no mailer
|
||||
// configured") rather than silently dropping them.
|
||||
func buildNoticeMailer() notices.EmailSender {
|
||||
host := os.Getenv("SMTP_HOST")
|
||||
if host == "" {
|
||||
return nil
|
||||
}
|
||||
port := 587
|
||||
if p := os.Getenv("SMTP_PORT"); p != "" {
|
||||
if v, err := strconv.Atoi(p); err == nil {
|
||||
port = v
|
||||
}
|
||||
}
|
||||
from := os.Getenv("SMTP_FROM")
|
||||
if from == "" {
|
||||
from = "no-reply@pangolin.app"
|
||||
}
|
||||
return &smtpNoticeMailer{
|
||||
host: host,
|
||||
port: port,
|
||||
username: os.Getenv("SMTP_USERNAME"),
|
||||
password: os.Getenv("SMTP_PASSWORD"),
|
||||
from: from,
|
||||
}
|
||||
}
|
||||
|
||||
// smtpNoticeMailer is a minimal notices.EmailSender over net/smtp, mirroring
|
||||
// auth.SMTPMailer's STARTTLS/PlainAuth send path (internal/auth/mailer.go)
|
||||
// but with a generic subject/body instead of fixed verification-code copy.
|
||||
type smtpNoticeMailer struct {
|
||||
host, username, password, from string
|
||||
port int
|
||||
}
|
||||
|
||||
func (m *smtpNoticeMailer) envelopeFrom() string {
|
||||
if a, err := mail.ParseAddress(m.from); err == nil {
|
||||
return a.Address
|
||||
}
|
||||
return m.from
|
||||
}
|
||||
|
||||
func (m *smtpNoticeMailer) Send(_ context.Context, to, subject, body string) error {
|
||||
msg := strings.Join([]string{
|
||||
"From: " + m.from,
|
||||
"To: " + to,
|
||||
"Subject: " + subject,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
body,
|
||||
}, "\r\n")
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", m.host, m.port)
|
||||
auth := smtp.PlainAuth("", m.username, m.password, m.host)
|
||||
if err := smtp.SendMail(addr, auth, m.envelopeFrom(), []string{to}, []byte(msg)); err != nil {
|
||||
return fmt.Errorf("nodectl: smtp send: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestParseNoticeAddFlags_Valid verifies the happy path parses all fields,
|
||||
// including --expires → 23:59:59 UTC on the given day.
|
||||
func TestParseNoticeAddFlags_Valid(t *testing.T) {
|
||||
in, err := parseNoticeAddFlags([]string{
|
||||
"-type", "important",
|
||||
"-title-zh", "维护通知",
|
||||
"-title-en", "Maintenance notice",
|
||||
"-body-zh", "今晚维护",
|
||||
"-body-en", "Maintenance tonight",
|
||||
"-link", "https://example.com/notice/1",
|
||||
"-expires", "2026-08-01",
|
||||
"-email",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseNoticeAddFlags: unexpected error: %v", err)
|
||||
}
|
||||
if in.Type != "important" {
|
||||
t.Errorf("Type = %q, want important", in.Type)
|
||||
}
|
||||
if in.TitleZH != "维护通知" || in.TitleEN != "Maintenance notice" {
|
||||
t.Errorf("titles = %q/%q", in.TitleZH, in.TitleEN)
|
||||
}
|
||||
if !in.Email {
|
||||
t.Errorf("Email = false, want true")
|
||||
}
|
||||
if in.ExpiresAt == nil {
|
||||
t.Fatalf("ExpiresAt is nil, want set")
|
||||
}
|
||||
want := time.Date(2026, 8, 1, 23, 59, 59, 0, time.UTC)
|
||||
if !in.ExpiresAt.Equal(want) {
|
||||
t.Errorf("ExpiresAt = %v, want %v", in.ExpiresAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseNoticeAddFlags_NoExpires verifies omitting --expires leaves it nil.
|
||||
func TestParseNoticeAddFlags_NoExpires(t *testing.T) {
|
||||
in, err := parseNoticeAddFlags([]string{
|
||||
"-type", "feature",
|
||||
"-title-zh", "新功能",
|
||||
"-title-en", "New feature",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseNoticeAddFlags: unexpected error: %v", err)
|
||||
}
|
||||
if in.ExpiresAt != nil {
|
||||
t.Errorf("ExpiresAt = %v, want nil", in.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseNoticeAddFlags_InvalidType is red case 1: -type outside the six
|
||||
// permitted values must fail before any Service/Store call.
|
||||
func TestParseNoticeAddFlags_InvalidType(t *testing.T) {
|
||||
_, err := parseNoticeAddFlags([]string{
|
||||
"-type", "bogus",
|
||||
"-title-zh", "标题",
|
||||
"-title-en", "Title",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("parseNoticeAddFlags: expected error for invalid -type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseNoticeAddFlags_MissingTitle is red case 2: missing -title-zh or
|
||||
// -title-en must fail.
|
||||
func TestParseNoticeAddFlags_MissingTitle(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "missing title-zh",
|
||||
args: []string{"-type", "important", "-title-en", "Title"},
|
||||
},
|
||||
{
|
||||
name: "missing title-en",
|
||||
args: []string{"-type", "important", "-title-zh", "标题"},
|
||||
},
|
||||
{
|
||||
name: "missing both",
|
||||
args: []string{"-type", "important"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parseNoticeAddFlags(tc.args)
|
||||
if err == nil {
|
||||
t.Fatalf("parseNoticeAddFlags(%v): expected error for missing title, got nil", tc.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseNoticeAddFlags_EmailNonImportant is red case 3: -email is only
|
||||
// allowed when -type=important.
|
||||
func TestParseNoticeAddFlags_EmailNonImportant(t *testing.T) {
|
||||
_, err := parseNoticeAddFlags([]string{
|
||||
"-type", "feature",
|
||||
"-title-zh", "标题",
|
||||
"-title-en", "Title",
|
||||
"-email",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("parseNoticeAddFlags: expected error for -email on non-important type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseNoticeAddFlags_BadExpiresFormat is red case 4: -expires must be
|
||||
// YYYY-MM-DD; other formats (slashes, timestamps, garbage) must fail.
|
||||
func TestParseNoticeAddFlags_BadExpiresFormat(t *testing.T) {
|
||||
cases := []string{
|
||||
"2026/08/01",
|
||||
"08-01-2026",
|
||||
"2026-08-01T00:00:00Z",
|
||||
"not-a-date",
|
||||
}
|
||||
for _, expires := range cases {
|
||||
t.Run(expires, func(t *testing.T) {
|
||||
_, err := parseNoticeAddFlags([]string{
|
||||
"-type", "important",
|
||||
"-title-zh", "标题",
|
||||
"-title-en", "Title",
|
||||
"-expires", expires,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("parseNoticeAddFlags(-expires=%q): expected error, got nil", expires)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user