feat(nodectl): notice add/list/revoke 子命令(校验+审计+可选邮件)

This commit is contained in:
wangjia
2026-07-13 14:01:50 +08:00
parent 75ec361542
commit 84b162e834
3 changed files with 417 additions and 1 deletions
+10 -1
View File
@@ -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
+271
View File
@@ -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
}
+136
View File
@@ -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)
}
})
}
}