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 rotate-pool -pool=consumable|premium [-concurrency=1]
|
||||||
nodectl providers [-pool=consumable|premium]
|
nodectl providers [-pool=consumable|premium]
|
||||||
nodectl bootstrap-token -node=UUID
|
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,
|
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) {
|
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)
|
return cmdProviders(ctx, args)
|
||||||
case "bootstrap-token":
|
case "bootstrap-token":
|
||||||
return cmdBootstrapToken(ctx, args)
|
return cmdBootstrapToken(ctx, args)
|
||||||
|
case "notice":
|
||||||
|
return cmdNotice(ctx, args)
|
||||||
case "-h", "--help", "help":
|
case "-h", "--help", "help":
|
||||||
usage()
|
usage()
|
||||||
return nil
|
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