diff --git a/cmd/ftl/cmd_schema.go b/cmd/ftl/cmd_schema.go index 34ce4816e0..8edcc619db 100644 --- a/cmd/ftl/cmd_schema.go +++ b/cmd/ftl/cmd_schema.go @@ -2,6 +2,7 @@ package main type schemaCmd struct { Get getSchemaCmd `default:"" cmd:"" help:"Retrieve the cluster FTL schema."` + Diff schemaDiffCmd `cmd:"" help:"Print any schema differences between this cluster and another cluster. Returns an exit code of 1 if there are differences."` Protobuf schemaProtobufCmd `cmd:"" help:"Generate protobuf schema mirroring the FTL schema structure."` Generate schemaGenerateCmd `cmd:"" help:"Stream the schema from the cluster and generate files from the template."` Import schemaImportCmd `cmd:"" help:"Import messages to the FTL schema."` diff --git a/cmd/ftl/cmd_schema_diff.go b/cmd/ftl/cmd_schema_diff.go new file mode 100644 index 0000000000..b7561d8407 --- /dev/null +++ b/cmd/ftl/cmd_schema_diff.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "os" + + "connectrpc.com/connect" + "github.com/hexops/gotextdiff" + "github.com/hexops/gotextdiff/myers" + "github.com/hexops/gotextdiff/span" + "github.com/mattn/go-isatty" + + ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1" + "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect" + schemapb "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/schema" + "github.com/TBD54566975/ftl/backend/schema" + "github.com/TBD54566975/ftl/internal/log" + "github.com/TBD54566975/ftl/internal/rpc" + "github.com/alecthomas/chroma/v2/quick" +) + +type schemaDiffCmd struct { + OtherEndpoint url.URL `arg:"" help:"Other endpoint URL to compare against."` + Color bool `help:"Enable colored output regardless of TTY."` +} + +func (d *schemaDiffCmd) Run(ctx context.Context, currentURL *url.URL) error { + other, err := schemaForURL(ctx, d.OtherEndpoint) + if err != nil { + return fmt.Errorf("failed to get other schema: %w", err) + } + current, err := schemaForURL(ctx, *currentURL) + if err != nil { + return fmt.Errorf("failed to get current schema: %w", err) + } + + edits := myers.ComputeEdits(span.URIFromPath(""), other.String(), current.String()) + diff := fmt.Sprint(gotextdiff.ToUnified(d.OtherEndpoint.String(), currentURL.String(), other.String(), edits)) + + color := d.Color || isatty.IsTerminal(os.Stdout.Fd()) + if color { + err = quick.Highlight(os.Stdout, diff, "diff", "terminal256", "solarized-dark") + if err != nil { + return fmt.Errorf("failed to highlight diff: %w", err) + } + } else { + fmt.Print(diff) + } + + // Similar to the `diff` command, exit with 1 if there are differences. + if diff != "" { + os.Exit(1) + } + + return nil +} + +func schemaForURL(ctx context.Context, url url.URL) (*schema.Schema, error) { + client := rpc.Dial(ftlv1connect.NewControllerServiceClient, url.String(), log.Error) + resp, err := client.PullSchema(ctx, connect.NewRequest(&ftlv1.PullSchemaRequest{})) + if err != nil { + return nil, fmt.Errorf("url %s: failed to pull schema: %w", url.String(), err) + } + + pb := &schemapb.Schema{} + for resp.Receive() { + msg := resp.Msg() + pb.Modules = append(pb.Modules, msg.Schema) + if !msg.More { + break + } + } + if resp.Err() != nil { + return nil, fmt.Errorf("url %s: failed to receive schema: %w", url.String(), resp.Err()) + } + + s, err := schema.FromProto(pb) + if err != nil { + return nil, fmt.Errorf("url %s: failed to parse schema: %w", url.String(), err) + } + + return s, nil +} diff --git a/go.mod b/go.mod index 33e8b98fb9..9549fe3548 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/TBD54566975/scaffolder v1.0.0 github.com/alecthomas/assert/v2 v2.10.0 github.com/alecthomas/atomic v0.1.0-alpha2 + github.com/alecthomas/chroma/v2 v2.14.0 github.com/alecthomas/concurrency v0.0.2 github.com/alecthomas/kong v0.9.0 github.com/alecthomas/kong-toml v0.2.0 @@ -121,7 +122,7 @@ require ( github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect - github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/hexops/gotextdiff v1.0.3 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect diff --git a/go.sum b/go.sum index e945c658f6..a807cf26b5 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZ github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8= github.com/alecthomas/atomic v0.1.0-alpha2/go.mod h1:zD6QGEyw49HIq19caJDc2NMXAy8rNi9ROrxtMXATfyI= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/concurrency v0.0.2 h1:Q3kGPtLbleMbH9lHX5OBFvJygfyFw29bXZKBg+IEVuo= github.com/alecthomas/concurrency v0.0.2/go.mod h1:GmuQb/iHX7mbNtPlC/WDzEFxDMB0HYFer2Qda9QTs7w= github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA=