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

Split Update module into components and add tests #173

Open
wants to merge 90 commits into
base: main
Choose a base branch
from

Conversation

yfyf
Copy link
Collaborator

@yfyf yfyf commented Aug 4, 2024

This is in an unfinished state (esp. the test part), but pushing this out for early review/feedback.

This PR does the following:

  • Refactors the core of Update module to use abstract interfaces for its external dependencies (RAUC, Curl/Connman, runtime configuration) rather than direct bindings / values.
  • Splits the Update.run function into a separate single transition function (run_step) and the infinite recursive process (run).
  • Exposes an alternative interface to the Update process as a functor over the external dependencies.
  • Adds a prototype test "framework" and a sample unit test for the Update module.

Some of the code is shifted around/generalized as a side-effect of the changes, but this PR should (hopefully!) not introduce any logical / semantic changes, it only creates new ways for interacting with the code and the sample tests.

This is WIP since several things need to be worked out in more detail:

  • The dependency interfaces are just straightforward wrappers around the functions used in the original code, but more thought could be put into this to figure out what exactly are we abstracting over.
  • The code split into the update_deps module/file is rather ad-hoc
  • The test "framework" is now quite basic in terms of what it can test, but my ocaml skills are too limited to figure out how to create mock interfaces neatly1.
  • Other server modules should probably share (at least) the RAUC abstract interfaces too?

I also left some TODOs as inline comments.

@knuton let me know of what you think about this "direction" and specific things that you think need to be done differently. If the general direction seems reasonable, I would attempt to flesh out the test framework and add more complex tests cases. Any ocaml-foo tips are also very welcome.

Apologies for the huge diff, the process was very unlinear, so did not produce structured git commits.

P.S. Is there a specific ocaml style used at Dividat? Maybe we could adopt one of the standard ocamlformat profiles?

Footnotes

  1. I want to be able to record every mock call and update the mocked responses at run time. I have an idea how to do this with a simple PPX "decorator", but no idea how to do it otherwise. There's ocaml-mock, but it's very primitive / requires lots of boilreplate.

@yfyf yfyf requested a review from knuton August 4, 2024 18:59
@yfyf yfyf changed the title WIP: refactor Update module to enable testing and add test prototype [WIP] Refactor Update module to enable testing and add test prototype Aug 4, 2024
@yfyf yfyf marked this pull request as draft August 4, 2024 19:01
@knuton
Copy link
Member

knuton commented Aug 6, 2024

Thanks!

Pushing to wrap up some feature work for an app release now, and I have only had the time for a first skim. Some high level comments for now.

  • Refactors the core of Update module to use abstract interfaces for its external dependencies (RAUC, Curl/Connman, runtime configuration) rather than direct bindings / values.

Good

  • Splits the Update.run function into a separate single transition function (run_step) and the infinite recursive process (run).

Good

  • Exposes an alternative interface to the Update process as a functor over the external dependencies.

Good, I think

  • Adds a prototype test "framework" and a sample unit test for the Update module.

Good, I think

  • The dependency interfaces are just straightforward wrappers around the functions used in the original code, but more thought could be put into this to figure out what exactly are we abstracting over.
  • The code split into the update_deps module/file is rather ad-hoc
  • Other server modules should probably share (at least) the RAUC abstract interfaces too?

I think these are related, and also what I was struggling with from a first look. For just this module, the abstractions seem a bit heavy-handed at the moment. My hunch is that this would benefit from thinking about it in a slightly wider scope, as in how could other modules consume this? Even if then possibly only implementing in the context of this one module at first.

@yfyf
Copy link
Collaborator Author

yfyf commented Aug 7, 2024 via email

@yfyf
Copy link
Collaborator Author

yfyf commented Aug 8, 2024

@knuton pushed updates that attempt to tidy-up / generalize things:

  • RAUC gets its own "service" interface, added the mark_good function to it which would make it immediately re-usable in other modules where it is used.
  • Could not think of a useful abstraction that would apply to the Curl wrapper, so essentially left it as is, only moved it to a separate file. It does not seem to be useful outside of the update module, as the only other place where proxy resolution is needed is gui, but there it is dealing with proxy settings at a much lower level. An alternative could be to create an UpdateHTTPClient interface/module, but I would prefer to not introduce new abstractions at this point.
  • Converted the ConfigInterface into a plain record, because it's supposed to be just data anyway.
  • Added init _ methods to the interfaces that provide a canonical way to initialize these services / wrappers, to be used in the top-level entrypoint (i.e. server.ml)

Also, answering my own question the mechanics, the Ocaml manual states that "a typical use of first-class modules is to select at run-time among several implementations of a signature", so I feel it kinda validates this approach.

P.S. I think I also figured out how to elegantly wrap the test implementations into a mock'ed interface, so will update the test prototype soon too.

@yfyf
Copy link
Collaborator Author

yfyf commented Aug 11, 2024

Went all in with splitting the code into modular components and testing all of them independently. Also abandoned the "mock" approach, instead went for stub-implementations of the dependencies.

The changes introduce 3 new abstractions/modules:

I also moved all of the runtime / nix-provided system configuration into a single config library as a starting point for a more structured approach towards config management. This is necessary to enable runtime test setup, but also sed-template-replacements-as-configuration is kind of a massive hack, so this at least consolidates it into once location and we can think where to go from here.

To run the tests, do

CURL_EXECUTABLE_PATH="" dune test -f

(The env var / path should later be specified in either the dune config for tests, or, more generally as part of nix-shell setup? Something to be discussed.)

There are separate tests for update_client and for the whole of update / update_service. Many more should be added for update_service, but I actually need specification on what is supposed to happen in various scenarios, it is a bit unclear.

I also littered the code with various TODOs and NOTEs for potential future improvements / fixes, but I tried to avoid changing any of the semantics, as before.

P.S. Since curl is invoked via a sub-process, could we not replace all of the manual-passing-around-of-proxy in the code by simply setting the HTTPS_PROXY env variable in the nix-equivalent of /etc/profile when it is setup in gui.ml? Unless you maintain separate proxy configs per connman service, but it does not seem so.

@knuton
Copy link
Member

knuton commented Aug 12, 2024

P.S. Since curl is invoked via a sub-process, could we not replace all of the manual-passing-around-of-proxy in the code by simply setting the HTTPS_PROXY env variable in the nix-equivalent of /etc/profile when it is setup in gui.ml? Unless you maintain separate proxy configs per connman service, but it does not seem so.

Assuming you are suggesting to just read HTTPS_PROXY from an environment variable at service startup once: I think this wouldn't work, because (a) the proxy should be picked up immediately (without restart) when the configuration is changed, and (b) the active proxy might change depending on which network service is the current gateway (which might change without the controller's involvement).

Copy link
Member

@knuton knuton left a comment

Choose a reason for hiding this comment

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

A collection of initial and mostly stylistic comments as I work my way from bottom up.

controller/server/update.mli Outdated Show resolved Hide resolved
controller/bindings/curl/curl.ml Outdated Show resolved Hide resolved
controller/config/config.ml Outdated Show resolved Hide resolved
controller/server/rauc_service.ml Outdated Show resolved Hide resolved
controller/server/rauc_service.ml Outdated Show resolved Hide resolved
controller/server/rauc_service.ml Outdated Show resolved Hide resolved
controller/server/rauc_service.ml Outdated Show resolved Hide resolved
controller/server/rauc_service.mli Outdated Show resolved Hide resolved
controller/server/update.mli Show resolved Hide resolved
Copy link
Member

@knuton knuton left a comment

Choose a reason for hiding this comment

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

Nice! I like the little spec language.

Going to send a lightweight spec list next.

controller/server/update_client.ml Outdated Show resolved Hide resolved
controller/server/update_client.mli Outdated Show resolved Hide resolved
controller/tests/update_tests.ml Outdated Show resolved Hide resolved
controller/tests/update_tests.ml Outdated Show resolved Hide resolved
controller/server/update.ml Outdated Show resolved Hide resolved
@knuton
Copy link
Member

knuton commented Aug 25, 2024

Sat down to write a spec, but instead accidentally refactored the run function to separate effectful queries from the version info processing logic: https://github.com/knuton/playos/commits/testing-pure-fun

Even with the added abstractions for effects, I think this kind of reorganization should make testing considerably easier.

@yfyf yfyf force-pushed the refactor-update-for-tests branch from 5e009c3 to a74ed8e Compare August 26, 2024 09:17
@yfyf
Copy link
Collaborator Author

yfyf commented Aug 26, 2024

refactored the run function to separate effectful queries from the version info processing logic

Incorporated these changes, but I went further and made evaluate_version_info totally pure, because it does not need to log the error case - it will be done anyway once the state machine enters the ErrorGettingVersionInfo state. So now it just returns the next state.

It could be potentially simplified even further by writing higher level helper funs for dealing with semver comparisons RAUC booted/primary slot semantics, but let's leave it for the future.

@yfyf yfyf marked this pull request as ready for review September 2, 2024 13:21
@yfyf
Copy link
Collaborator Author

yfyf commented Sep 2, 2024

Pushed some semi-final (hopefully!) changes:

  • UpdateClient now looks up the current proxy dynamically from connman (as it was done originally)
  • No more global test state - all mocks/stubs are now object instances that get initialized on each test case, tests are parameterized over a test_context. (I tried a module/functor based approach, but it just felt like re-inventing classes and objects/instances rather than using them directly, so I went ahead with objects. It still requires some boilerplate, but it's somewhat more straightforward in mu opinion).
  • some minor improvements / clearing TODOs and dead code
  • I noticed Config.System now partially duplicates the role of Info. I added an include Config.System in info.ml for now, not sure it's optimal.

At this point I see two main remaining things:

  • More test scenarios for UpdateService
  • I kinda want to rename mock everywhere into stub, because mock would imply doing some assertions on how the mock was used (was-method-called, etc), whereas here we just have a test implementation. What do you think?

@yfyf
Copy link
Collaborator Author

yfyf commented Sep 3, 2024

@knuton pushed updates to the earlier tests and a new set of tests that attempt to exhaustively check all the possible (162) combinations as per the spec you provided, which I distilled into this function.

Some cases (well, actually about a third of them) are failing now (see attachment), because the current implementation does not match the spec. Many are irrelevant and are due to the spec not distinguishing between "doing nothing" and producing an error/warning (and then doing nothing), which can be easily resolved. But some are really questionable, e.g. in test case 90 UpdateService would attempt to update the inactive slot, whereas nothing should be done (according to the spec, but also probably a good idea in practice).

I am using an intermediate representation of expected outcomes for now to provide more room for interpreting the results.

Will add the side-effect / IO / failure tests next.

P.S. Alcotest has automagic terminal width detection that leads to truncated output and unreadable test case descriptions. We are running an outdated version of alcotest that does not support the ALCOTEST_COLUMNS env setting. If you want to get the full output locally, you have to open a "wide" terminal window and run ALCOTEST_SHOW_ERRORS=1 dune test --no-buffer.

@knuton
Copy link
Member

knuton commented Sep 5, 2024

This is very interesting

  • I kinda want to rename mock everywhere into stub, because mock would imply doing some assertions on how the mock was used (was-method-called, etc), whereas here we just have a test implementation. What do you think?

I think you are right, though I admit that I wouldn't have minded calling them "mocks".

Some cases (well, actually about a third of them) are failing now (see attachment), because the current implementation does not match the spec. Many are irrelevant and are due to the spec not distinguishing between "doing nothing" and producing an error/warning (and then doing nothing), which can be easily resolved. But some are really questionable, e.g. in test case 90 UpdateService would attempt to update the inactive slot, whereas nothing should be done (according to the spec, but also probably a good idea in practice).

Great. I was indeed struggling with this distinction when drafting a spec, and I think it was part of what led me to think about separating data from interpretation and action.

You did warm me to the goldfish memory design, so maybe it's enough to keep the action separate. What do you think about an interpreter ("free monad") approach to the effects? This may help check the expected action/effect often being "nothing", while still being able to expect a specific interpretation. This may bridge to your intermediate representations.

@yfyf yfyf force-pushed the refactor-update-for-tests branch 2 times, most recently from 1811312 to 138613e Compare September 10, 2024 12:26
@yfyf yfyf changed the title [WIP] Refactor Update module to enable testing and add test prototype Split Update module into components and add tests Sep 10, 2024
@yfyf
Copy link
Collaborator Author

yfyf commented Sep 10, 2024

Updates:

I consider this complete now, unless there are specific things that you want me to change. Commits could be squashed too if you prefer that.

Thoughts for the future: as we discussed, the "absolute worst" scenario is if we release a version of PlayOS that boots and seems "good", but that somehow breaks the auto-update process (and therefore the system never self-updates). Therefore, it would be a good idea to be a bit more cautious before marking the newly installed system as good, e.g. add checks for whether RAUC is functional, the UpdateService loop completes successfully, etc. Now it only checks whether it is able to boot?

@guyonvarch guyonvarch added the reviewable Ready for initial or iterative review label Sep 19, 2024
Copy link
Member

@guyonvarch guyonvarch left a comment

Choose a reason for hiding this comment

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

Running the VM, with ./build vm and ./result/bin/run-in-vm, and openning the backdor with socat (see command given by the output of run-in-vm), I am getting at a high rate the following error message:

[  107.255679] playos-controller[774]: playos-controller: [ERROR] failed to get version information: E("The name de.pengutronix.rauc was not provided by any .service files")
[  107.255861] playos-controller[774]: playos-controller: [INFO] update mechanism: (ErrorGettingVersionInfo
[  107.255974] playos-controller[774]:  "E(\"The name de.pengutronix.rauc was not provided by any .service files\")")

I don’t get this message on the main branch.

controller/server/rauc_service.ml Show resolved Hide resolved
@yfyf
Copy link
Collaborator Author

yfyf commented Sep 20, 2024

Running the VM, with ./build vm and ./result/bin/run-in-vm, and openning the backdor with socat (see command given by the output of run-in-vm), I am getting at a high rate the following error message:
<..>

RAUC is not available when running PlayOS via run-in-vm, so this error is kind of expected? It might have been logged differently (or not logged?), but the fact that it is failing to interact with RAUC should not have changed.

@yfyf yfyf marked this pull request as draft September 20, 2024 10:00
@yfyf
Copy link
Collaborator Author

yfyf commented Sep 20, 2024

I am converting this back to draft, because while implementing end-to-end tests (PR incoming today), I realized this code will break when deployed, because the nix template variables are being replaced only in specific modules and they are moved into config.ml in this PR. Will fix it ASAP.

@guyonvarch
Copy link
Member

RAUC is not available when running PlayOS via run-in-vm, so this error is kind of expected? It might have been logged differently (or not logged?), but the fact that it is failing to interact with RAUC should not have changed.

Yes, but the problem is the super high rate (multiple times each second), making the backdoor when developping useless, and flooding the logs. In main, the error message shows up, but at a slow rate (every x seconds).

@yfyf
Copy link
Collaborator Author

yfyf commented Sep 20, 2024

Yes, but the problem is the super high rate (multiple times each second), making the backdoor when developping useless, and flooding the logs. In main, the error message shows up, but at a slow rate (every x seconds).

Ah, I see, strange - I will look into it!

@yfyf
Copy link
Collaborator Author

yfyf commented Sep 23, 2024

Yes, but the problem is the super high rate (multiple times each second), making the backdoor when developping useless, and flooding the logs. In main, the error message shows up, but at a slow rate (every x seconds).

Ah, I see, strange - I will look into it!

@guyonvarch should be fixed now, see the last commit. This was quite an ugly bug, thank you for pointing it out!

During refactoring they were turned into variables/promises that got
resolved once and afterwards would no longer run, hence no sleep
timeouts were being applied and UpdateService was busy-looping without a
break!

Added (a rather ugly) test case to cover this. Could be incorporated
into prop-tests, but would be easier once the Update state is ADT is
refactored.

Thanks to @guyonvarch for spotting this one.
Also ensure that all the template variables exist in the target file.
Commit 3cdf833f23977a77a62cfabf737f3ae75275698e introduced a bug
by expecting curl to be in PATH.
Without this, logs look like this:

        playos-controller: [INFO] update mechanism: (UpToDate ((latest <opaque>) (booted <opaque>) (inactive <opaque>)))

This also causes e2e tests to fail, which expect to observe the version
number in the logs.
Previously logs were split into multiple lines, like so:

    [INFO] update mechanism: (Downloading
        (url http://update-server.local/9.9.9-TEST/playos-9.9.9-TEST.raucb)
        (version 9.9.9-TEST))

since the Downloading state has been simplified to only include the
version and no url, it now gets logged in a single line.
Previously, the base/proxy-and-update e2e test passed, because
the controller considered `latest < {booted,inactive}` an error state
and triggered an update check "quickly" (in 30 seconds).

This was changed with commit d8053bd
which changed the interpration of this state to `UpToDate` i.e. a
non-error state, therefore entering a long sleep of 1 hour.

As a work-around, we restart the controller to trigger an update check.
This is ugly and potentially hides bugs, should be eliminated once
the runtime configuration option is added.
Copy link
Member

@guyonvarch guyonvarch left a comment

Choose a reason for hiding this comment

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

First batch of comments on the non test part, I’ll have a look at the test afterward.

controller/default.nix Outdated Show resolved Hide resolved
controller/server/update_client.ml Outdated Show resolved Hide resolved
controller/server/update_client.ml Outdated Show resolved Hide resolved
controller/server/update.ml Outdated Show resolved Hide resolved
controller/server/update.ml Show resolved Hide resolved
@guyonvarch guyonvarch added changes suggested Asking for changes before next round of reviewing and removed reviewable Ready for initial or iterative review labels Nov 14, 2024
Co-authored-by: Joris Guyonvarch <joris@guyonvarch.me>
Copy link
Member

@guyonvarch guyonvarch left a comment

Choose a reason for hiding this comment

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

I mostly read through the tests, but without necessarily getting everything, as there are non trivial parts.

Ocaml doesn’t help in this regard, requiring to put the most general function at the end of the file, you get the big picture in the end.

controller/tests/update_client_tests.ml Outdated Show resolved Hide resolved
Comment on lines +272 to +273
(* Using string repr is a horrible hack, but avoids having to
pattern match on every variant in the state ADT *)
Copy link
Member

Choose a reason for hiding this comment

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

Why would we have to match on every variant in the ADT, could you provide an example?

Also, I don’t understand at this point where the magic pattern is created, is it in Update.sexp_of_state?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Why would we have to match on every variant in the ADT, could you provide an example?

Because we need to define equality for state, which would require defining equality for all the cases, including e.g. UpToDate of version_info.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Also, I don’t understand at this point where the magic pattern is created, is it in Update.sexp_of_state?

Do you mean it's unclear how it's used? Here are a few places:

controller/tests/update_test_helpers.ml Outdated Show resolved Hide resolved
controller/tests/update_test_helpers.ml Outdated Show resolved Hide resolved
@yfyf yfyf added reviewable Ready for initial or iterative review and removed changes suggested Asking for changes before next round of reviewing labels Nov 15, 2024
Co-authored-by: Joris Guyonvarch <joris@guyonvarch.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
reviewable Ready for initial or iterative review
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants