Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions .tsk/architecture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { defineAnalyzer } from "tsk";
// Module-relative path of the command line, which owns all terminal output.
const cliPackage = "internal/cli";
const terminalPackage = cliPackage + "/terminal";
// Module-relative paths of the SPECTRE service protocol's generated packages.
const protocolPackages = ["spectrepb", "spectrepb/spectrepbconnect"];
// The server's API boundary, which converts protocol messages to domain types.
const protocolBoundary = ["internal/server", "internal/server/wire", "internal/server/client"];

export default defineAnalyzer({
name: "architecture",
Expand All @@ -13,7 +17,11 @@ belongs in internal/. Keep a startup helper in a main package with a
//nolint:architecture comment explaining why it is not application logic.

Only ${cliPackage} and its sub-packages may import ${terminalPackage}, because
terminal output is the domain of the command line. Domain packages return data.`,
terminal output is the domain of the command line. Domain packages return data.

Only the SPECTRE server's API boundary, ${protocolBoundary.join(", ")}, may
import ${protocolPackages.join(" or ")}, because protocol messages stop at the
API. Everything else uses internal/model types, which the boundary converts.`,
run(pass) {
// Generated test main packages live in the build cache, not the source tree.
if (pass.pkg.path().endsWith(".test")) {
Expand All @@ -25,16 +33,24 @@ terminal output is the domain of the command line. Domain packages return data.`
// An external test package belongs to the package it tests.
const owner = relative(pass.pkg.path()).replace(/_test$/, "");
const inCLI = owner === cliPackage || owner.startsWith(cliPackage + "/");
const atProtocolBoundary = protocolBoundary.includes(owner) || protocolPackages.includes(owner);
for (const file of pass.files) {
if (!inCLI) {
for (const spec of file.imports) {
const path = spec?.path?.value.slice(1, -1);
if (spec !== null && path !== undefined && relative(path) === terminalPackage) {
pass.report({
pos: spec.pos(),
message: `only ${cliPackage} may import ${path}: return data and let the command line write it`,
});
}
for (const spec of file.imports) {
const path = spec?.path?.value.slice(1, -1);
if (spec === null || path === undefined) {
continue;
}
if (!inCLI && relative(path) === terminalPackage) {
pass.report({
pos: spec.pos(),
message: `only ${cliPackage} may import ${path}: return data and let the command line write it`,
});
}
if (!atProtocolBoundary && protocolPackages.includes(relative(path))) {
pass.report({
pos: spec.pos(),
message: `only the SPECTRE server API boundary may import ${path}: use internal/model types, converted by internal/server/wire`,
});
}
}
if (pass.pkg.name() !== "main" || pass.fset.position(file.package).filename.endsWith("_test.go")) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package storage

import "example.com/architecture/spectrepb" // want `only the SPECTRE server API boundary may import example.com/architecture/spectrepb`

var _ = spectrepb.Divergence{}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package storage_test

import "example.com/architecture/spectrepb/spectrepbconnect" // want `only the SPECTRE server API boundary may import`

var _ = spectrepbconnect.Client{}
5 changes: 5 additions & 0 deletions .tsk/testdata/architecture/internal/server/wire/wire.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package wire

import "example.com/architecture/spectrepb"

var _ = spectrepb.Divergence{}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package wire_test

import "example.com/architecture/spectrepb"

var _ = spectrepb.Divergence{}
3 changes: 3 additions & 0 deletions .tsk/testdata/architecture/spectrepb/spectrepb.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package spectrepb

type Divergence struct{}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package spectrepbconnect

import "example.com/architecture/spectrepb"

type Client struct{ Divergence spectrepb.Divergence }
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
- The SPECTRE service protocol in `proto/spectre/v1` and its generated
`spectrepb` packages are also public. Keep its definitions independent of
internal types.
- Use protocol messages only at the SPECTRE server's API boundary: its handler
in `internal/server`, the conversions in `internal/server/wire`, and
`internal/server/client`. Everything else uses `internal/model` types. The
`architecture` linter enforces this.
- Expose only extension points that have been explicitly agreed. Add each as one
public contract and one `With...` option.
- Never expose an internal type in the public API, even through an alias. Mirror
Expand Down
7 changes: 4 additions & 3 deletions cli_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/block/spectre/internal/cli"
"github.com/block/spectre/internal/server"
"github.com/block/spectre/internal/server/client"
"github.com/block/spectre/internal/server/reporter"
"github.com/block/spectre/internal/server/storage"
"github.com/block/spectre/internal/supervisor"
)
Expand All @@ -24,8 +25,8 @@ func reportingFlags() []string {
}

// expectedReporting is the reporter config that reportingFlags parse to.
func expectedReporting() server.ReporterConfig {
return server.ReporterConfig{
func expectedReporting() reporter.Config {
return reporter.Config{
Client: client.Config{URL: "http://server.example"},
Service: "users",
Environment: "production",
Expand Down Expand Up @@ -81,7 +82,7 @@ func TestSuperviseCommandReadsFlagsFromEnvironment(t *testing.T) {
"--schema-dir=" + writeSchemaDir(t),
})
assert.NoError(t, err)
expected := server.ReporterConfig{
expected := reporter.Config{
Client: client.Config{URL: "http://server.example"},
Service: "invoices",
Environment: "production-us-west-2",
Expand Down
49 changes: 18 additions & 31 deletions internal/cli/divergences.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ import (

"github.com/alecthomas/errors"
. "github.com/alecthomas/types/optional"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"

"github.com/block/spectre/internal/model"
"github.com/block/spectre/internal/server"
"github.com/block/spectre/internal/server/client"
pb "github.com/block/spectre/spectrepb"
"github.com/block/spectre/internal/server/wire"
)

// divergencesCommand groups the commands for the divergences mirrors reported.
Expand All @@ -33,33 +32,28 @@ type divergencesListCommand struct {

func (c *divergencesListCommand) Run(ctx context.Context, out Output) error {
// No reported value is empty, so an empty flag leaves its filter unset.
request := &pb.ListDivergencesRequest{
Service: Zero(c.Service).Ptr(),
Environment: Zero(c.Environment).Ptr(),
Revision: Zero(c.Revision).Ptr(),
Node: Zero(c.Node).Ptr(),
filter := model.DivergenceFilter{
Service: Zero(c.Service),
Environment: Zero(c.Environment),
Revision: Zero(c.Revision),
Node: Zero(c.Node),
}
response, err := client.New(c.Client).ListDivergences(ctx, request)
divergences, err := client.New(c.Client).ListDivergences(ctx, filter)
if err != nil {
return errors.WithStack(err)
}
if c.JSON {
return writeJSON(out, response)
return writeJSON(out, divergences)
}
divergences := response.GetDivergences()
palette := out.Palette
if len(divergences) == 0 {
_, err := fmt.Fprintln(out.Writer, palette.Dim("No divergences."))
return errors.Wrap(err, "write divergences")
}
for _, divergence := range divergences {
description, err := describe(divergence)
if err != nil {
return err
}
since := "quarantined " + divergence.GetQuarantine().GetSince().AsTime().UTC().Format(time.RFC3339)
_, err = fmt.Fprintf(out.Writer, "%s %s %s\n %s\n %s\n", palette.Bold(divergence.GetService()), divergence.GetRevision(),
palette.Dim(divergence.GetEnvironment()), palette.Red(description), palette.Dim(since))
since := "quarantined " + divergence.Quarantine.Since.UTC().Format(time.RFC3339)
_, err = fmt.Fprintf(out.Writer, "%s %s %s\n %s\n %s\n", palette.Bold(divergence.Service), divergence.Revision,
palette.Dim(divergence.Environment), palette.Red(describe(divergence.Direction, divergence.Quarantine.Reason)), palette.Dim(since))
if err != nil {
return errors.Wrap(err, "write divergences")
}
Expand All @@ -78,29 +72,22 @@ func (c *divergencesClearCommand) Run(ctx context.Context) error {

// describe renders a quarantine as the mirror logged it, with the direction,
// route, and node it names.
func describe(divergence *pb.Divergence) (string, error) {
direction, err := server.DirectionFromMessage(divergence.GetDirection())
if err != nil {
return "", errors.Wrap(err, "describe divergence")
}
reason, err := server.ReasonFromMessage(divergence.GetQuarantine())
if err != nil {
return "", errors.Wrap(err, "describe divergence")
}
func describe(direction model.Direction, reason model.QuarantineReason) string {
text := fmt.Sprintf("%s: %s", direction, reason)
if traffic, ok := reason.Traffic.Get(); ok {
text = fmt.Sprintf("%s %s %s: %s", direction, traffic.Protocol, traffic.Route, reason)
}
if node, ok := reason.Node.Get(); ok {
text += fmt.Sprintf(" (node %s)", node)
}
return text, nil
return text
}

func writeJSON(out Output, message proto.Message) error {
data, err := protojson.MarshalOptions{Multiline: true}.Marshal(message)
// writeJSON prints divergences as the SPECTRE service protocol's list response.
func writeJSON(out Output, divergences []model.Divergence) error {
data, err := wire.DivergencesJSON(divergences)
if err != nil {
return errors.Wrap(err, "encode JSON")
return errors.WithStack(err)
}
_, err = fmt.Fprintln(out.Writer, string(data))
return errors.Wrap(err, "write JSON")
Expand Down
64 changes: 36 additions & 28 deletions internal/cli/divergences_internal_test.go
Original file line number Diff line number Diff line change
@@ -1,60 +1,54 @@
package cli

import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"

"connectrpc.com/connect"
"github.com/alecthomas/assert/v2"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
. "github.com/alecthomas/types/optional"

"github.com/block/spectre/internal/model"
"github.com/block/spectre/internal/server"
"github.com/block/spectre/internal/server/client"
"github.com/block/spectre/internal/server/storage"
pb "github.com/block/spectre/spectrepb"
"github.com/block/spectre/spectrepb/spectrepbconnect"
)

func TestDivergencesListCommand(t *testing.T) {
httpServer := httptest.NewServer(server.NewService(storage.NewMemory()).Handler())
t.Cleanup(httpServer.Close)
rpc := spectrepbconnect.NewSpectreServiceClient(httpServer.Client(), httpServer.URL)
config := client.Config{URL: httpServer.URL}
first := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
divergent := &pb.Divergence{
divergent := model.Divergence{
Service: "users",
Environment: "production",
Revision: "abc123",
Direction: pb.Direction_DIRECTION_INGRESS,
Quarantine: &pb.Quarantine{
Since: timestamppb.New(first.Add(time.Minute)),
Kind: pb.Quarantine_KIND_DIVERGENT,
Traffic: &pb.Traffic{Direction: pb.Direction_DIRECTION_INGRESS, Protocol: "http", Route: "GET /v1/users"},
Node: proto.String("users"),
Direction: model.Ingress,
Quarantine: model.Quarantine{Since: first.Add(time.Minute), Reason: model.QuarantineReason{
Kind: model.QuarantineDivergent,
Traffic: Some(model.Traffic{Direction: model.Ingress, Protocol: model.ProtocolHTTP, Route: "GET /v1/users"}),
Node: Some("users"),
Differences: []string{"body.name"},
},
}},
}
unmatched := &pb.Divergence{
unmatched := model.Divergence{
Service: "users",
Environment: "staging",
Revision: "abc123",
Direction: pb.Direction_DIRECTION_EGRESS,
Quarantine: &pb.Quarantine{
Since: timestamppb.New(first),
Kind: pb.Quarantine_KIND_UNMATCHED,
Direction: model.Egress,
Quarantine: model.Quarantine{Since: first, Reason: model.QuarantineReason{
Kind: model.QuarantineUnmatched,
Detail: "no matching reference call",
},
}},
}
for _, divergence := range []*pb.Divergence{divergent, unmatched} {
_, err := rpc.Quarantine(t.Context(), connect.NewRequest(&pb.QuarantineRequest{Divergence: divergence}))
assert.NoError(t, err)
for _, divergence := range []model.Divergence{divergent, unmatched} {
assert.NoError(t, client.New(config).Quarantine(t.Context(), divergence))
}

var text strings.Builder
command := divergencesListCommand{Client: client.Config{URL: httpServer.URL}}
command := divergencesListCommand{Client: config}
assert.NoError(t, command.Run(t.Context(), Output{Writer: &text}))
assert.Equal(t, `users abc123 production
ingress http GET /v1/users: response differences: body.name (node users)
Expand All @@ -68,7 +62,21 @@ users abc123 staging
command.JSON = true
command.Node = "users"
assert.NoError(t, command.Run(t.Context(), Output{Writer: &data}))
decoded := &pb.ListDivergencesResponse{}
assert.NoError(t, protojson.Unmarshal([]byte(data.String()), decoded))
assert.True(t, proto.Equal(&pb.ListDivergencesResponse{Divergences: []*pb.Divergence{divergent}}, decoded), "%v", decoded)
// Protobuf JSON output varies its whitespace, so compare decoded values.
var expected, printed any
assert.NoError(t, json.Unmarshal([]byte(`{"divergences": [{
"service": "users",
"environment": "production",
"revision": "abc123",
"direction": "DIRECTION_INGRESS",
"quarantine": {
"since": "2026-10-01T12:01:00Z",
"kind": "KIND_DIVERGENT",
"traffic": {"direction": "DIRECTION_INGRESS", "protocol": "http", "route": "GET /v1/users"},
"node": "users",
"differences": ["body.name"]
}
}]}`), &expected))
assert.NoError(t, json.Unmarshal([]byte(data.String()), &printed))
assert.Equal(t, expected, printed)
}
39 changes: 39 additions & 0 deletions internal/model/divergence.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package model

import . "github.com/alecthomas/types/optional"

// Divergence is the quarantine that the mirror of a service environment
// reported for one of its directions.
type Divergence struct {
Service string `json:"service"`
Environment string `json:"environment"`
// Revision is the commit of the candidate the mirror runs.
Revision string `json:"revision"`
// Direction is the proxy that quarantined the candidate.
Direction Direction `json:"direction"`
Quarantine Quarantine `json:"quarantine"`
}

// DivergenceFilter selects divergences. An absent field matches every value.
type DivergenceFilter struct {
Service Option[string]
Environment Option[string]
Revision Option[string]
// Node matches only divergences on routes of that migration state node.
Node Option[string]
}

// Matches reports whether divergence passes every field of the filter.
func (f DivergenceFilter) Matches(divergence Divergence) bool {
return matches(f.Service, Some(divergence.Service)) &&
matches(f.Environment, Some(divergence.Environment)) &&
matches(f.Revision, Some(divergence.Revision)) &&
matches(f.Node, divergence.Quarantine.Reason.Node)
}

// matches reports whether value passes a filter. An absent filter matches
// every value, including an absent one.
func matches(filter, value Option[string]) bool {
expected, ok := filter.Get()
return !ok || value == Some(expected)
}
Loading
Loading