节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
11.84 KiB
Stardust
package scenario

import (
	"context"
	"fmt"
	"net/http"
	"strings"
	"time"

	"staragent-conformance/internal/fakeserver"
	"staragent-conformance/internal/protocol"
)

// Definition is one conformance scenario.
type Definition struct {
	Name        string
	Description string
	Run         func(context.Context, *Env) error
}

// Definitions returns the built-in scenarios in execution order.
func Definitions() []Definition {
	return []Definition{
		{Name: "basic", Description: "login, heartbeat, WebSocket command, reply and persisted Code/Secret", Run: runBasic},
		{Name: "http-fallback", Description: "commands delivered in the ping response when WebSocket is unavailable", Run: runHTTPFallback},
		{Name: "dedup", Description: "a duplicated command Id is executed and replied only once", Run: runDedup},
		{Name: "expired", Description: "an expired command is replied with Status=3 (cancelled)", Run: runExpired},
		{Name: "reconnect", Description: "WebSocket disconnect is followed by reconnect and command delivery", Run: runReconnect},
		{Name: "events", Description: "failed event uploads are retried until accepted", Run: runEvents},
	}
}

// Names returns the scenario names.
func Names() []string {
	defs := Definitions()
	names := make([]string, 0, len(defs))
	for _, def := range defs {
		names = append(names, def.Name)
	}

	return names
}

// Select resolves "all" or a comma-separated scenario list.
func Select(name string) ([]Definition, error) {
	defs := Definitions()
	if strings.EqualFold(strings.TrimSpace(name), "all") {
		return defs, nil
	}
	byName := make(map[string]Definition, len(defs))
	for _, def := range defs {
		byName[def.Name] = def
	}

	var selected []Definition
	for _, part := range strings.Split(name, ",") {
		part = strings.TrimSpace(part)
		if part == "" {
			continue
		}
		def, ok := byName[strings.ToLower(part)]
		if !ok {
			return nil, fmt.Errorf("unknown scenario %q (available: %s)", part, strings.Join(Names(), ", "))
		}
		selected = append(selected, def)
	}
	if len(selected) == 0 {
		return nil, fmt.Errorf("no scenario selected")
	}

	return selected, nil
}

func runBasic(ctx context.Context, e *Env) error {
	if err := e.StartAgent(); err != nil {
		return fmt.Errorf("start agent: %w", err)
	}

	st, err := e.WaitFor("first login", func(s *fakeserver.State) bool { return len(s.Logins) >= 1 })
	if err != nil {
		return err
	}
	first := st.Logins[0]
	if first.Status != http.StatusOK {
		return fmt.Errorf("first login failed: status=%d error=%s", first.Status, first.Error)
	}
	if first.Code != "" || first.Secret != "" {
		return fmt.Errorf("first login must dynamically register with empty Code/Secret, got Code=%q Secret=%q", first.Code, first.Secret)
	}
	if first.ProductCode == "" {
		return fmt.Errorf("login request did not include ProductCode")
	}
	if first.Node == nil {
		return fmt.Errorf("login request did not include Node")
	}
	if !protocol.IsDateTime(first.Node.Compile) {
		return fmt.Errorf("Node.Compile %q is not yyyy-MM-dd HH:mm:ss", first.Node.Compile)
	}
	if first.ResponseCode == "" || first.ResponseSecret == "" {
		return fmt.Errorf("first login response did not include Code/Secret")
	}
	if first.ResponseToken == "" {
		return fmt.Errorf("first login response did not include Token")
	}

	st, err = e.WaitFor("heartbeat", func(s *fakeserver.State) bool { return len(s.Pings) >= 1 })
	if err != nil {
		return err
	}
	ping := st.Pings[0]
	if ping.Status != http.StatusOK {
		return fmt.Errorf("first ping was rejected: status=%d error=%s", ping.Status, ping.Error)
	}
	if ping.TimeField <= 0 {
		return fmt.Errorf("PingInfo.Time must be Unix milliseconds, got %d", ping.TimeField)
	}

	if _, err := e.WaitFor("WebSocket connection", func(s *fakeserver.State) bool { return s.WSConnections >= 1 }); err != nil {
		return err
	}
	if _, err := e.WaitFor("application-level Pong", func(s *fakeserver.State) bool { return s.Pongs >= 1 }); err != nil {
		return err
	}

	const commandId int64 = 1001
	if err := e.Command(protocol.CommandModel{Id: commandId, Command: "node/synctime"}, "ws"); err != nil {
		return fmt.Errorf("enqueue command: %w", err)
	}
	_, replies, err := e.WaitForReply(commandId, 1)
	if err != nil {
		return err
	}
	if replies[0].Reply.Status != protocol.StatusCompleted {
		return fmt.Errorf("command %d reply status=%d, want %d", commandId, replies[0].Reply.Status, protocol.StatusCompleted)
	}
	if replies[0].Reply.Data == "" {
		return fmt.Errorf("command %d reply did not include Data", commandId)
	}

	pingsBeforeRestart := len(st.Pings)
	if err := e.RestartAgent(); err != nil {
		return fmt.Errorf("restart agent: %w", err)
	}
	st, err = e.WaitFor("second login after restart", func(s *fakeserver.State) bool { return len(s.Logins) >= 2 })
	if err != nil {
		return err
	}
	second := st.Logins[1]
	if second.Status != http.StatusOK {
		return fmt.Errorf("second login failed: status=%d error=%s", second.Status, second.Error)
	}
	if second.Code != st.IssuedCode || second.Secret != st.IssuedSecret {
		return fmt.Errorf("agent did not persist Code/Secret across restart: sent Code=%q Secret=%q", second.Code, second.Secret)
	}
	if second.ResponseToken == "" {
		return fmt.Errorf("second login response did not include a new Token")
	}

	st, err = e.WaitFor("heartbeat after restart", func(s *fakeserver.State) bool {
		return len(s.Pings) > pingsBeforeRestart && s.Pings[len(s.Pings)-1].Status == http.StatusOK
	})
	if err != nil {
		return err
	}

	return nil
}

func runHTTPFallback(ctx context.Context, e *Env) error {
	if err := e.Control.SetWSDisabled(ctx, true); err != nil {
		return fmt.Errorf("disable websocket: %w", err)
	}
	if err := e.StartAgent(); err != nil {
		return fmt.Errorf("start agent: %w", err)
	}
	if _, err := e.WaitFor("login", func(s *fakeserver.State) bool { return len(s.Logins) >= 1 }); err != nil {
		return err
	}
	if _, err := e.WaitFor("heartbeat", func(s *fakeserver.State) bool { return len(s.Pings) >= 1 }); err != nil {
		return err
	}

	const commandId int64 = 2001
	if err := e.Command(protocol.CommandModel{Id: commandId, Command: "node/synctime"}, "http"); err != nil {
		return fmt.Errorf("enqueue HTTP fallback command: %w", err)
	}
	st, replies, err := e.WaitForReply(commandId, 1)
	if err != nil {
		return err
	}
	if replies[0].Reply.Status != protocol.StatusCompleted {
		return fmt.Errorf("HTTP fallback command reply status=%d, want %d", replies[0].Reply.Status, protocol.StatusCompleted)
	}
	if !commandDeliveredInPing(st, commandId) {
		return fmt.Errorf("command %d was not delivered in a ping response", commandId)
	}
	if st.WSConnections != 0 {
		return fmt.Errorf("websocket should stay disabled, but %d connection(s) were accepted", st.WSConnections)
	}

	return nil
}

func runDedup(ctx context.Context, e *Env) error {
	if err := e.StartAgent(); err != nil {
		return fmt.Errorf("start agent: %w", err)
	}
	if _, err := e.WaitFor("login and websocket", func(s *fakeserver.State) bool {
		return len(s.Logins) >= 1 && s.WSConnections >= 1 && s.WSActive >= 1
	}); err != nil {
		return err
	}

	const commandId int64 = 3001
	cmd := protocol.CommandModel{Id: commandId, Command: "node/synctime"}
	if err := e.Command(cmd, "ws"); err != nil {
		return fmt.Errorf("enqueue first command: %w", err)
	}
	if err := e.Command(cmd, "ws"); err != nil {
		return fmt.Errorf("enqueue duplicate command: %w", err)
	}

	st, replies, err := e.WaitForReply(commandId, 1)
	if err != nil {
		return err
	}
	if replies[0].Reply.Status != protocol.StatusCompleted {
		return fmt.Errorf("command reply status=%d, want %d", replies[0].Reply.Status, protocol.StatusCompleted)
	}
	if !e.Sleep(1500 * time.Millisecond) {
		return e.Ctx.Err()
	}
	st, err = e.Control.State(ctx)
	if err != nil {
		return fmt.Errorf("read state: %w", err)
	}
	if got := countReplies(st, commandId); got != 1 {
		return fmt.Errorf("duplicate command produced %d replies, want exactly 1", got)
	}
	if got := countDeliveries(st, commandId); got < 2 {
		return fmt.Errorf("expected 2 deliveries of command %d, got %d", commandId, got)
	}

	return nil
}

func runExpired(ctx context.Context, e *Env) error {
	if err := e.StartAgent(); err != nil {
		return fmt.Errorf("start agent: %w", err)
	}
	if _, err := e.WaitFor("login and websocket", func(s *fakeserver.State) bool {
		return len(s.Logins) >= 1 && s.WSConnections >= 1
	}); err != nil {
		return err
	}

	const commandId int64 = 4001
	cmd := protocol.CommandModel{
		Id:      commandId,
		Command: "node/synctime",
		Expire:  protocol.FormatDateTime(time.Now().Add(-time.Minute)),
	}
	if err := e.Command(cmd, "ws"); err != nil {
		return fmt.Errorf("enqueue expired command: %w", err)
	}
	_, replies, err := e.WaitForReply(commandId, 1)
	if err != nil {
		return err
	}
	if replies[0].Reply.Status != protocol.StatusCancelled {
		return fmt.Errorf("expired command reply status=%d, want %d (cancelled)", replies[0].Reply.Status, protocol.StatusCancelled)
	}
	if replies[0].Reply.Data == "" {
		return fmt.Errorf("expired command reply did not include Data")
	}

	return nil
}

func runReconnect(ctx context.Context, e *Env) error {
	if err := e.StartAgent(); err != nil {
		return fmt.Errorf("start agent: %w", err)
	}
	st, err := e.WaitFor("login, websocket and heartbeat", func(s *fakeserver.State) bool {
		return len(s.Logins) >= 1 && s.WSConnections >= 1 && len(s.Pings) >= 1 && s.Pongs >= 1
	})
	if err != nil {
		return err
	}
	initialConns := st.WSConnections
	initialPings := len(st.Pings)
	initialPongs := st.Pongs

	if err := e.Control.Kick(ctx); err != nil {
		return fmt.Errorf("kick websocket: %w", err)
	}
	st, err = e.WaitFor("websocket reconnect", func(s *fakeserver.State) bool {
		return s.WSConnections > initialConns && s.WSActive >= 1
	})
	if err != nil {
		return err
	}
	if _, err := e.WaitFor("heartbeat after reconnect", func(s *fakeserver.State) bool {
		return len(s.Pings) > initialPings
	}); err != nil {
		return err
	}
	if _, err := e.WaitFor("pong after reconnect", func(s *fakeserver.State) bool {
		return s.Pongs > initialPongs
	}); err != nil {
		return err
	}

	const commandId int64 = 5001
	if err := e.Command(protocol.CommandModel{Id: commandId, Command: "node/synctime"}, "ws"); err != nil {
		return fmt.Errorf("enqueue command after reconnect: %w", err)
	}
	_, replies, err := e.WaitForReply(commandId, 1)
	if err != nil {
		return err
	}
	if replies[0].Reply.Status != protocol.StatusCompleted {
		return fmt.Errorf("post-reconnect command reply status=%d, want %d", replies[0].Reply.Status, protocol.StatusCompleted)
	}

	return nil
}

func runEvents(ctx context.Context, e *Env) error {
	if err := e.Control.FailEvents(ctx, 2, http.StatusInternalServerError); err != nil {
		return fmt.Errorf("configure event failures: %w", err)
	}
	if err := e.StartAgent(); err != nil {
		return fmt.Errorf("start agent: %w", err)
	}
	if _, err := e.WaitFor("login", func(s *fakeserver.State) bool { return len(s.Logins) >= 1 }); err != nil {
		return err
	}

	st, err := e.WaitFor("event upload retry", func(s *fakeserver.State) bool {
		return len(s.EventAttempts) >= 3 && len(s.Events) >= 1
	})
	if err != nil {
		return fmt.Errorf("%w (the events scenario expects the agent to report at least one event, e.g. Startup)", err)
	}
	if st.EventAttempts[0].Status != http.StatusInternalServerError || st.EventAttempts[1].Status != http.StatusInternalServerError {
		return fmt.Errorf("expected the first two event uploads to fail, got statuses %d and %d",
			st.EventAttempts[0].Status, st.EventAttempts[1].Status)
	}
	if st.EventAttempts[len(st.EventAttempts)-1].Status != http.StatusOK {
		return fmt.Errorf("last event attempt status=%d, want 200", st.EventAttempts[len(st.EventAttempts)-1].Status)
	}
	for _, rec := range st.Events {
		switch rec.Event.Type {
		case "info", "alert", "error":
		default:
			return fmt.Errorf("event %q has invalid Type %q", rec.Event.Name, rec.Event.Type)
		}
		if rec.Event.Name == "" {
			return fmt.Errorf("event has empty Name")
		}
		if rec.Event.Time <= 0 {
			return fmt.Errorf("event %q has invalid Time %d", rec.Event.Name, rec.Event.Time)
		}
	}

	return nil
}