Skip to content

Commit

Permalink
init
Browse files Browse the repository at this point in the history
lint

hostname parsing

start filling out the local interface

move files to admin package and fill out interface methods

integration test

integration test

harness

use loopback instead of hardcoding IPs

better comment
  • Loading branch information
deniseli committed Jun 7, 2024
1 parent 551eb48 commit 50aecf8
Show file tree
Hide file tree
Showing 10 changed files with 258 additions and 34 deletions.
76 changes: 76 additions & 0 deletions backend/controller/admin/cmd_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package admin

import (
"context"
"errors"
"net"
"net/url"

"connectrpc.com/connect"
ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1"
"github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect"
)

type CmdClient interface {
Ping(ctx context.Context, req *connect.Request[ftlv1.PingRequest]) (*connect.Response[ftlv1.PingResponse], error)

// List configuration.
ConfigList(ctx context.Context, req *connect.Request[ftlv1.ListConfigRequest]) (*connect.Response[ftlv1.ListConfigResponse], error)

// Get a config value.
ConfigGet(ctx context.Context, req *connect.Request[ftlv1.GetConfigRequest]) (*connect.Response[ftlv1.GetConfigResponse], error)

// Set a config value.
ConfigSet(ctx context.Context, req *connect.Request[ftlv1.SetConfigRequest]) (*connect.Response[ftlv1.SetConfigResponse], error)

// Unset a config value.
ConfigUnset(ctx context.Context, req *connect.Request[ftlv1.UnsetConfigRequest]) (*connect.Response[ftlv1.UnsetConfigResponse], error)

// List secrets.
SecretsList(ctx context.Context, req *connect.Request[ftlv1.ListSecretsRequest]) (*connect.Response[ftlv1.ListSecretsResponse], error)

// Get a secret.
SecretGet(ctx context.Context, req *connect.Request[ftlv1.GetSecretRequest]) (*connect.Response[ftlv1.GetSecretResponse], error)

// Set a secret.
SecretSet(ctx context.Context, req *connect.Request[ftlv1.SetSecretRequest]) (*connect.Response[ftlv1.SetSecretResponse], error)

// Unset a secret.
SecretUnset(ctx context.Context, req *connect.Request[ftlv1.UnsetSecretRequest]) (*connect.Response[ftlv1.UnsetSecretResponse], error)
}

// NewCmdClient takes the service client and endpoint flag received by the cmd interface
// and returns an appropriate interface for the cmd library to use.
//
// If the controller is not present AND endpoint is local, then inject a purely-local
// implementation of the interface so that the user does not need to spin up a controller
// just to run the `ftl config/secret` commands. Otherwise, return back the gRPC client.
func NewCmdClient(ctx context.Context, adminClient ftlv1connect.AdminServiceClient, endpoint *url.URL) (CmdClient, error) {
_, err := adminClient.Ping(ctx, connect.NewRequest(&ftlv1.PingRequest{}))
if isConnectUnavailableError(err) && isEndpointLocal(endpoint) {
return newLocalCmdClient(ctx), nil
}
return adminClient, nil
}

func isConnectUnavailableError(err error) bool {
var connectErr *connect.Error
if errors.As(err, &connectErr) {
return connectErr.Code() == connect.CodeUnavailable
}
return false
}

func isEndpointLocal(endpoint *url.URL) bool {
h := endpoint.Hostname()
ips, err := net.LookupIP(h)
if err != nil {
panic(err.Error())
}
for _, netip := range ips {
if netip.IsLoopback() {
return true
}
}
return false
}
45 changes: 45 additions & 0 deletions backend/controller/admin/cmd_client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package admin

import (
"net/url"
"testing"

"github.com/alecthomas/assert/v2"
)

func TestIsEndpointLocal(t *testing.T) {
tests := []struct {
Name string
Endpoint string
Want bool
}{
{
Name: "DefaultLocalhost",
Endpoint: "http://localhost:8892",
Want: true,
},
{
Name: "NumericLocalhost",
Endpoint: "http://127.0.0.1:8892",
Want: true,
},
{
Name: "TooLow",
Endpoint: "http://126.255.255.255:8892",
Want: false,
},
{
Name: "TooHigh",
Endpoint: "http://128.0.0.1:8892",
Want: false,
},
}

for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
u, err := url.Parse(test.Endpoint)
assert.NoError(t, err)
assert.Equal(t, isEndpointLocal(u), test.Want)
})
}
}
59 changes: 59 additions & 0 deletions backend/controller/admin/local_cmd_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package admin

import (
"context"

"connectrpc.com/connect"
ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1"
"github.com/TBD54566975/ftl/common/configuration"
)

// localCmdClient reads and writes to local projectconfig files without making any network
// calls. It allows us to interface with local ftl-project.toml files without needing to
// start a controller.
type localCmdClient struct {
as *AdminService
}

func newLocalCmdClient(ctx context.Context) *localCmdClient {
cm := configuration.ConfigFromContext(ctx)
sm := configuration.SecretsFromContext(ctx)
return &localCmdClient{NewAdminService(cm, sm)}
}

// Ping will always return a healthy response because localCmdClient is purely local.
func (l *localCmdClient) Ping(ctx context.Context, req *connect.Request[ftlv1.PingRequest]) (*connect.Response[ftlv1.PingResponse], error) {
return connect.NewResponse(&ftlv1.PingResponse{}), nil
}

func (l *localCmdClient) ConfigList(ctx context.Context, req *connect.Request[ftlv1.ListConfigRequest]) (*connect.Response[ftlv1.ListConfigResponse], error) {
return l.as.ConfigList(ctx, req)
}

func (l *localCmdClient) ConfigGet(ctx context.Context, req *connect.Request[ftlv1.GetConfigRequest]) (*connect.Response[ftlv1.GetConfigResponse], error) {
return l.as.ConfigGet(ctx, req)
}

func (l *localCmdClient) ConfigSet(ctx context.Context, req *connect.Request[ftlv1.SetConfigRequest]) (*connect.Response[ftlv1.SetConfigResponse], error) {
return l.as.ConfigSet(ctx, req)
}

func (l *localCmdClient) ConfigUnset(ctx context.Context, req *connect.Request[ftlv1.UnsetConfigRequest]) (*connect.Response[ftlv1.UnsetConfigResponse], error) {
return l.as.ConfigUnset(ctx, req)
}

func (l *localCmdClient) SecretsList(ctx context.Context, req *connect.Request[ftlv1.ListSecretsRequest]) (*connect.Response[ftlv1.ListSecretsResponse], error) {
return l.as.SecretsList(ctx, req)
}

func (l *localCmdClient) SecretGet(ctx context.Context, req *connect.Request[ftlv1.GetSecretRequest]) (*connect.Response[ftlv1.GetSecretResponse], error) {
return l.as.SecretGet(ctx, req)
}

func (l *localCmdClient) SecretSet(ctx context.Context, req *connect.Request[ftlv1.SetSecretRequest]) (*connect.Response[ftlv1.SetSecretResponse], error) {
return l.as.SecretSet(ctx, req)
}

func (l *localCmdClient) SecretUnset(ctx context.Context, req *connect.Request[ftlv1.UnsetSecretRequest]) (*connect.Response[ftlv1.UnsetSecretResponse], error) {
return l.as.SecretUnset(ctx, req)
}
18 changes: 9 additions & 9 deletions cmd/ftl/cmd_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import (

"connectrpc.com/connect"

"github.com/TBD54566975/ftl/backend/controller/admin"
ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1"
"github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect"
cf "github.com/TBD54566975/ftl/common/configuration"
"github.com/alecthomas/types/optional"
)
Expand Down Expand Up @@ -54,8 +54,8 @@ type configListCmd struct {
Module string `optional:"" arg:"" placeholder:"MODULE" help:"List configuration only in this module."`
}

func (s *configListCmd) Run(ctx context.Context, admin ftlv1connect.AdminServiceClient) error {
resp, err := admin.ConfigList(ctx, connect.NewRequest(&ftlv1.ListConfigRequest{
func (s *configListCmd) Run(ctx context.Context, adminClient admin.CmdClient) error {
resp, err := adminClient.ConfigList(ctx, connect.NewRequest(&ftlv1.ListConfigRequest{
Module: &s.Module,
IncludeValues: &s.Values,
}))
Expand Down Expand Up @@ -84,8 +84,8 @@ Returns a JSON-encoded configuration value.
`
}

func (s *configGetCmd) Run(ctx context.Context, admin ftlv1connect.AdminServiceClient) error {
resp, err := admin.ConfigGet(ctx, connect.NewRequest(&ftlv1.GetConfigRequest{
func (s *configGetCmd) Run(ctx context.Context, adminClient admin.CmdClient) error {
resp, err := adminClient.ConfigGet(ctx, connect.NewRequest(&ftlv1.GetConfigRequest{
Ref: configRefFromRef(s.Ref),
}))
if err != nil {
Expand All @@ -107,7 +107,7 @@ type configSetCmd struct {
Value *string `arg:"" placeholder:"VALUE" help:"Configuration value (read from stdin if omitted)." optional:""`
}

func (s *configSetCmd) Run(ctx context.Context, scmd *configCmd, admin ftlv1connect.AdminServiceClient) error {
func (s *configSetCmd) Run(ctx context.Context, scmd *configCmd, adminClient admin.CmdClient) error {
var err error
var config []byte
if s.Value != nil {
Expand Down Expand Up @@ -135,7 +135,7 @@ func (s *configSetCmd) Run(ctx context.Context, scmd *configCmd, admin ftlv1conn
if provider, ok := scmd.provider().Get(); ok {
req.Provider = &provider
}
_, err = admin.ConfigSet(ctx, connect.NewRequest(req))
_, err = adminClient.ConfigSet(ctx, connect.NewRequest(req))
if err != nil {
return err
}
Expand All @@ -146,14 +146,14 @@ type configUnsetCmd struct {
Ref cf.Ref `arg:"" help:"Configuration reference in the form [<module>.]<name>."`
}

func (s *configUnsetCmd) Run(ctx context.Context, scmd *configCmd, admin ftlv1connect.AdminServiceClient) error {
func (s *configUnsetCmd) Run(ctx context.Context, scmd *configCmd, adminClient admin.CmdClient) error {
req := &ftlv1.UnsetConfigRequest{
Ref: configRefFromRef(s.Ref),
}
if provider, ok := scmd.provider().Get(); ok {
req.Provider = &provider
}
_, err := admin.ConfigUnset(ctx, connect.NewRequest(req))
_, err := adminClient.ConfigUnset(ctx, connect.NewRequest(req))
if err != nil {
return err
}
Expand Down
18 changes: 9 additions & 9 deletions cmd/ftl/cmd_secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import (
"github.com/mattn/go-isatty"
"golang.org/x/term"

"github.com/TBD54566975/ftl/backend/controller/admin"
ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1"
"github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect"
cf "github.com/TBD54566975/ftl/common/configuration"
)

Expand Down Expand Up @@ -58,8 +58,8 @@ type secretListCmd struct {
Module string `optional:"" arg:"" placeholder:"MODULE" help:"List secrets only in this module."`
}

func (s *secretListCmd) Run(ctx context.Context, admin ftlv1connect.AdminServiceClient) error {
resp, err := admin.SecretsList(ctx, connect.NewRequest(&ftlv1.ListSecretsRequest{
func (s *secretListCmd) Run(ctx context.Context, adminClient admin.CmdClient) error {
resp, err := adminClient.SecretsList(ctx, connect.NewRequest(&ftlv1.ListSecretsRequest{
Module: &s.Module,
IncludeValues: &s.Values,
}))
Expand Down Expand Up @@ -87,8 +87,8 @@ Returns a JSON-encoded secret value.
`
}

func (s *secretGetCmd) Run(ctx context.Context, admin ftlv1connect.AdminServiceClient) error {
resp, err := admin.SecretGet(ctx, connect.NewRequest(&ftlv1.GetSecretRequest{
func (s *secretGetCmd) Run(ctx context.Context, adminClient admin.CmdClient) error {
resp, err := adminClient.SecretGet(ctx, connect.NewRequest(&ftlv1.GetSecretRequest{
Ref: configRefFromRef(s.Ref),
}))
if err != nil {
Expand All @@ -108,7 +108,7 @@ type secretSetCmd struct {
Ref cf.Ref `arg:"" help:"Secret reference in the form [<module>.]<name>."`
}

func (s *secretSetCmd) Run(ctx context.Context, scmd *secretCmd, admin ftlv1connect.AdminServiceClient) error {
func (s *secretSetCmd) Run(ctx context.Context, scmd *secretCmd, adminClient admin.CmdClient) error {
// Prompt for a secret if stdin is a terminal, otherwise read from stdin.
var err error
var secret []byte
Expand Down Expand Up @@ -142,7 +142,7 @@ func (s *secretSetCmd) Run(ctx context.Context, scmd *secretCmd, admin ftlv1conn
if provider, ok := scmd.provider().Get(); ok {
req.Provider = &provider
}
_, err = admin.SecretSet(ctx, connect.NewRequest(req))
_, err = adminClient.SecretSet(ctx, connect.NewRequest(req))
if err != nil {
return err
}
Expand All @@ -153,14 +153,14 @@ type secretUnsetCmd struct {
Ref cf.Ref `arg:"" help:"Secret reference in the form [<module>.]<name>."`
}

func (s *secretUnsetCmd) Run(ctx context.Context, scmd *secretCmd, admin ftlv1connect.AdminServiceClient) error {
func (s *secretUnsetCmd) Run(ctx context.Context, scmd *secretCmd, adminClient admin.CmdClient) error {
req := &ftlv1.UnsetSecretRequest{
Ref: configRefFromRef(s.Ref),
}
if provider, ok := scmd.provider().Get(); ok {
req.Provider = &provider
}
_, err := admin.SecretUnset(ctx, connect.NewRequest(req))
_, err := adminClient.SecretUnset(ctx, connect.NewRequest(req))
if err != nil {
return err
}
Expand Down
4 changes: 3 additions & 1 deletion cmd/ftl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
kongtoml "github.com/alecthomas/kong-toml"

"github.com/TBD54566975/ftl"
"github.com/TBD54566975/ftl/backend/controller/admin"
"github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect"
cf "github.com/TBD54566975/ftl/common/configuration"
"github.com/TBD54566975/ftl/common/projectconfig"
Expand Down Expand Up @@ -121,7 +122,8 @@ func main() {

adminServiceClient := rpc.Dial(ftlv1connect.NewAdminServiceClient, cli.Endpoint.String(), log.Error)
ctx = rpc.ContextWithClient(ctx, adminServiceClient)
kctx.BindTo(adminServiceClient, (*ftlv1connect.AdminServiceClient)(nil))
adminClient, err := admin.NewCmdClient(ctx, adminServiceClient, cli.Endpoint)
kctx.BindTo(adminClient, (*admin.CmdClient)(nil))

controllerServiceClient := rpc.Dial(ftlv1connect.NewControllerServiceClient, cli.Endpoint.String(), log.Error)
ctx = rpc.ContextWithClient(ctx, controllerServiceClient)
Expand Down
6 changes: 6 additions & 0 deletions common/projectconfig/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,9 @@ func TestDefaultToRootWhenModuleDirsMissing(t *testing.T) {
}),
)
}

func TestConfigCmdWithoutController(t *testing.T) {
in.RunWithoutController(t, "configs-ftl-project.toml",
in.ExecWithExpectedOutput("value\n", "ftl", "config", "get", "key"),
)
}
3 changes: 3 additions & 0 deletions common/projectconfig/testdata/go/configs-ftl-project.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[global]
[global.configuration]
key = "inline://InZhbHVlIg"
11 changes: 11 additions & 0 deletions integration/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,17 @@ func Exec(cmd string, args ...string) Action {
}
}

// ExecWithExpectedOutput runs a command from the test working directory.
// The output is captured and is compared with the expected output.
func ExecWithExpectedOutput(want string, cmd string, args ...string) Action {
return func(t testing.TB, ic TestContext) {
Infof("Executing: %s %s", cmd, shellquote.Join(args...))
output, err := ftlexec.Capture(ic, ic.workDir, cmd, args...)
assert.NoError(t, err)
assert.Equal(t, output, []byte(want))
}
}

// ExecWithOutput runs a command from the test working directory.
// The output is captured and is returned as part of the error.
func ExecWithOutput(cmd string, args ...string) Action {
Expand Down
Loading

0 comments on commit 50aecf8

Please sign in to comment.