Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

abstract action creation, refactor proxy, add proxy tests #308

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

patrickdemers6
Copy link
Collaborator

@patrickdemers6 patrickdemers6 commented Aug 31, 2024

Description

Lots of refactoring with the overarching goal being to add unit tests for the HTTP proxy.

  • Move action creation out of vehicle. Now, a vehicle has two methods for actions: ExecuteAction and ExecuteUnsignedAction.
  • Now that go is upgraded to >= 1.22, use the routing enhancements.
  • Add unit tests for HTTP proxy.
  • General refactoring.

Type of change

Please select all options that apply to this change:

  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Bug fix (non-breaking change which fixes an issue)
  • Documentation update

Checklist:

Confirm you have completed the following steps:

  • My code follows the style of this project.
  • I have performed a self-review of my code.
  • I have made corresponding updates to the documentation.
  • I have added/updated unit tests to cover my changes.

pkg/proxy/proxy.go Outdated Show resolved Hide resolved
pkg/proxy/proxy.go Outdated Show resolved Hide resolved
@patrickdemers6 patrickdemers6 force-pushed the proxy-refactor-add-tests branch 2 times, most recently from ac3073b to e4c6c28 Compare September 8, 2024 03:12
@patrickdemers6 patrickdemers6 changed the title refactor http proxy and add unit tests abstract action creation, refactor proxy, add proxy tests Sep 8, 2024
@patrickdemers6 patrickdemers6 force-pushed the proxy-refactor-add-tests branch 3 times, most recently from ea5517a to bce1ebc Compare September 9, 2024 17:07
if len(args) == 0 {
return errors.New("missing COMMAND")
}

info, err := checkReadiness(args[0], car != nil && car.PrivateKeyAvailable(), acct != nil, car != nil)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't re-use the same symbol for a package and a variable. This would break if there's a conflict in exported names/methods.

On the other hand, I agree that car isn't ideal here. I'd suggest just using v instead; this shorthand for a locally-scoped variable is pretty common in Go.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed with the non-awesomeness of shadowing the package with variables, but nothing will "break" though. Once shadowed, you just won't be able to use the underlying package while shadowed. For example:
https://go.dev/play/p/7VhEqRIMXYa
The main downside of shadowing is that you don't know what's what without really paying attention, and that can lead to confusion when reading the code.

I would recommend against v as a var name; this is common for receivers, but for one of many params in funcs can have have sizable scopes, it'd be better to have slightly more meaningful names.

In this particular case, adding an import alias like veh or vehicl or pvehicle or vehiclep or vehipkg or somethingthatsnotvehicle might work better, because it'll be very obvious that vehicle veh.Vehicle means veh is a package, and then the rest of the code reads nicely with vehicle as a subject to everything.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I think I'd just keep car in here.

@@ -247,40 +265,40 @@ var commands = map[string]*Command{
help: "Unlock vehicle",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.Unlock(ctx)
handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API should be designed for the consumer, not the developer or the tester, and this API is cumbersome for the consumer.

If we want to abstract the creation of the protobufs, I'd suggest we wrap the code on the other direction:

func (v *Vehicle) Unlock(ctx context.Context) error {
     return v.executeAction(ctx, action.Unlock())
}

If the motive here is to improve testability, then this could also be accomplished by implementing a test connector.Connector that functions similarly to net/http/httptest in that it records requests/responses and you can verify they are as expected.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with focusing on consumer vs. author for the API design.
What we lose with moving to this ExecuteAction model is that we don't have an easy way to know which actions are available to us, especially because ExecuteAction takes a loose interface{} type which prevents tools from inferring this statically.
Couple of thoughts:

  • Moving the actions out of Vehicle and into an ExecuteAction has the advantage of clearly separating the domains of what we can do with a vehicle (Connect, Disconnect, AddKey, etc) from all the actions. But we'd need vehicle (the package) to specify an Action interface to make it easy to statically understand what can be passed in. The implementation details of CarServer vs VSSEC should be kept hidden just like they were before.
  • If we keep all the actions straight on Vehicle (like Seth suggests), we can test either by having a recorder (like Seth suggests), and/or having an action provider with a big interface that would be swappable for tests. The difference here is that it wouldn't clutter the API from a consumer point of view, and would make things testable a bit more directly than with a recorder. (We could also have the private executeAction be a swappable func for tests).

// The action can be a *carserver.Action_VehicleAction or a *vcsec.UnsignedMessage.
// Actions are created using the action package.
func (v *Vehicle) ExecuteAction(ctx context.Context, action interface{}) error {
switch action := action.(type) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

_, err := v.getCarServerResponse(ctx, action)
return err
case *vcsec.UnsignedMessage:
encodedPayload, err := proto.Marshal(action)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Mostly for aesthetic reasons, it'd be nice if getCarServerResponse and getVCSECResult had the same signature.

}, nil
// Vehicles must have the public part of skey enrolled on their keychains.
// (This is a command-authentication key, not a TLS key.)
func New(ctx context.Context, skey protocol.ECDHPrivateKey, cacheSize int, accountProvider AccountProvider) *Proxy {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider returning an (always nil) error for the sake of API stability. This would also allow API stability going forward of the implementation changes in a way that re-introduces the possibility of failing.

Context("vehicle commands", func() {
Context("invalid VIN", func() {
It("returns not found", func() {
rr := sendRequest(http.MethodPost, "/api/1/vehicles/ABC/command/honk_horn", authorizationToken, nil)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

download

Fine.

Expect(action).ToNot(BeNil())
Expect(action.SubMessage).ToNot(BeNil())
Expect(action.SubMessage).To(BeAssignableToTypeOf(&vcsec.UnsignedMessage_RKEAction{}))
Expect(action.SubMessage.(*vcsec.UnsignedMessage_RKEAction).RKEAction).To(Equal(vcsec.RKEAction_E_RKE_ACTION_AUTO_SECURE_VEHICLE))
Copy link
Collaborator

@sethterashima sethterashima Sep 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Golang protobuf code checks for nil receivers and returns default values, so I think you could just do:

action := action.AutoSecureVehicle()
Expect(action.GetRKEAction()).To(Equal(vcsec.RKEAction_E_RKE_ACTION_AUTO_SECURE_VEHICLE))

Note that this wouldn't work for RKEAction_E_RKE_ACTION_UNLOCK, since the protobuf definition breaks the convention of reserving the 0 (default) value for UNKNOWN.

Copy/Paste this comment a bunch of times below.

@@ -0,0 +1,164 @@
// Code generated by MockGen. DO NOT EDIT.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know Go makes it hard to avoid including autogenerated code, but... any way to avoid this without breaking go test out of the box?

@@ -22,5 +22,11 @@ jobs:
make format
git diff --exit-code

- name: Mocks up to date
run: |
go install go.uber.org/mock/mockgen@latest
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we peg this to the version in our go.mod instead of latest?

case "reset_valet_pin":
return func(v *vehicle.Vehicle) error { return v.ResetValetPin(ctx) }, nil
return action.ResetValetPin(), nil
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tangent: maybe Pin -> PIN to be consistent here.

}

func (p *Proxy) markUnsupportedVIN(vin string) {
func (p *Proxy) markSignedCommandsUnsupportedVIN(vin string) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the VIN suffix? (The other one doesn't have it)

p.unsupported.Store(vin, true)
}

func (p *Proxy) isNotSupported(vin string) bool {
func (p *Proxy) signedCommandUnsupported(vin string) bool {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous one says Commands, this one says Command, but they're dealing with the same thing.

proxyReq.Header.Set(xff, strings.Join(previous, ", "))
}
proxyReq.URL.Host = host
acct := req.Context().Value(accountContext).(Account)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be good to check the type assertion here.

return p.accountProvider(token, proxyProtocolVersion)
}

func writeResponseError(w http.ResponseWriter, code int, err error) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writeErrorResponse?

@@ -0,0 +1,2 @@
// Package action contains functions for creating actions to send to vehicles.
package action
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: if kept, we could consider having action live under vehicle (so pkg/vehicle/action) (an idea).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...or internal/action? What's the value in exporting? Plus, assigning generic names like "action" to exported packages is frowned on.

"fmt"
"time"

carserver "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: we should remove all these redundant carserver aliases.

if len(args) == 0 {
return errors.New("missing COMMAND")
}

info, err := checkReadiness(args[0], car != nil && car.PrivateKeyAvailable(), acct != nil, car != nil)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I think I'd just keep car in here.

@@ -247,40 +265,40 @@ var commands = map[string]*Command{
help: "Unlock vehicle",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.Unlock(ctx)
handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with focusing on consumer vs. author for the API design.
What we lose with moving to this ExecuteAction model is that we don't have an easy way to know which actions are available to us, especially because ExecuteAction takes a loose interface{} type which prevents tools from inferring this statically.
Couple of thoughts:

  • Moving the actions out of Vehicle and into an ExecuteAction has the advantage of clearly separating the domains of what we can do with a vehicle (Connect, Disconnect, AddKey, etc) from all the actions. But we'd need vehicle (the package) to specify an Action interface to make it easy to statically understand what can be passed in. The implementation details of CarServer vs VSSEC should be kept hidden just like they were before.
  • If we keep all the actions straight on Vehicle (like Seth suggests), we can test either by having a recorder (like Seth suggests), and/or having an action provider with a big interface that would be swappable for tests. The difference here is that it wouldn't clutter the API from a consumer point of view, and would make things testable a bit more directly than with a recorder. (We could also have the private executeAction be a swappable func for tests).

//
// The action can be a *carserver.Action_VehicleAction or a *vcsec.UnsignedMessage.
// Actions are created using the action package.
func (v *Vehicle) ExecuteAction(ctx context.Context, action interface{}) error {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we keep this model, I'd vote for Execute(context.Context, Action) rather than ExecuteAction(context.Context, interface{}).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants