Skip to content

feat(apps): plugin source in its own model #9825

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

Closed
wants to merge 21 commits into from

Conversation

mariusandra
Copy link
Collaborator

Problem

If we want to offer apps, we will need to store more than just one source text field on the plugin. This PR makes that possible. It also paves the wave for generic multi-file plugins, if we ever want them.

Changes

  • Create a new PluginSource model for storing source for a plugin
  • Deprecate the existing source field, and create a migration to move old data over. Remove the field from the serializer.
  • Add API endpoints to get/update the plugin source in a compact way (the frontend won't deal with separate PluginSource objects, but will get all files and send all changes to plugins/:id/source in one object {"index.ts": "code", "plugin.json": "{}"}).
  • Replaced editing of just the config json with editing of the full plugin.json. This means that now the plugin's name is also editable inside the JSON, not as a separate field. This greatly simplified the code loading paths in the plugin server -> archive, source or local -> they all work very similarly now.

2022-05-17 22 52 29

How did you test this code?

  • Wrote tests for the new parts in django and the plugin server
  • Tried a lot of changes in the interface myself

Next steps

  • Add frontend.tsx and transpiling support.
  • Load that on the frontend.

@mariusandra mariusandra requested review from macobo and yakkomajuri May 17, 2022 20:54
@macobo
Copy link
Contributor

macobo commented May 18, 2022

Q: What is the operational effect of this to plugin-server? How does it affect mean-time-to-ingest events?

Copy link
Contributor

@yakkomajuri yakkomajuri left a comment

Choose a reason for hiding this comment

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

Left some comments.

I'm also curious about the answer to @macobo's question.

Another thing I'd really love in general is if we could split the behavioral changes from the scaffolding (models, migration, etc).

Why? That makes it so much easier to revert if things go sour. We first migrate the data, check things work as expected, then merge the behavioral changes, which we can revert independently.

Although as long as this is well tested we should be good 👍

@@ -176,6 +175,15 @@ def get_queryset(self):
return queryset
return queryset.none()

def get_plugin_with_permissions(self, reason="installation"):
plugin = self.get_object()
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: small thing but we probably don't need to call this if the validation below fails i.e. this can be moved all the way down.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmm.. not sure what do you mean?

def get_plugin_with_permissions(self, reason="installation"):
    plugin = self.get_object() # move this down? but then `plugin.organization` won't work...?
    organization = self.organization
    if plugin.organization != organization:
        raise NotFound()
    if not can_install_plugins(self.organization):
        raise PermissionDenied(f"Plugin {reason} is not available for the current organization!")
    return plugin

Copy link
Contributor

Choose a reason for hiding this comment

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

ah brain 💨

if sources.get(key):
if sources[key].source != value:
performed_changes = True
if value is None:
Copy link
Contributor

Choose a reason for hiding this comment

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

do we want to allow this? can't we prevent breaking a plugin at an earlier stage (here) vs. finding out a file is missing when loading the plugin in the plugin server?

Or what's the legitimate reason for this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The interface doesn't support it yet, but as we basically allow creating and editing any source file now, you may also want to delete the files. Thus if you pass {"frontend.tsx": null} (or undefined), that source file will be removed, instead of being rewritten to "". Just pass "" if you want an empty file.

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, you need to explicitly send a file as a key with null/undefined/None to delete it. If you omit the key altogether in the update call, the source file won't be touched at all.

@mariusandra
Copy link
Collaborator Author

Q: What is the operational effect of this to plugin-server? How does it affect mean-time-to-ingest events?

I have not done measurements, but it should be virtually negligible.

The only thing that changed is the way we load code for "source code" plugins (written with the editor). Now instead of getting the source from the Plugin model (that's already fetched by this point), we make two extra async database query per plugin to fetch its source and possibly fetch and transpile the frontend source.

That may sound like a lot (N+1 queries anyone?), but:

  1. Most plugins are installed from the plugin repository, and nothing changed from them. For example we still extract the index.ts from a zip when loading those, or load a local file for local plugins. Only source plugins changed.
  2. I was contemplating using a join/subquery to add the source to the fetched plugin model, to mimic the current API and remove the extra query, but decided against it. Basically the complexity we get with adding new fields to the query that differ from the model, keeping those in sync whenever plugins reload, or using some separate source caching layer, is not really worth the complexity, as the win is likely measured in milliseconds. Without these "optimizations", the codepath for loading a plugin is now exactly the same for local, remote and source plugins, with one changed "getFile" function.

@mariusandra
Copy link
Collaborator Author

Another thing I'd really love in general is if we could split the behavioral changes from the scaffolding (models, migration, etc).

Why? That makes it so much easier to revert if things go sour. We first migrate the data, check things work as expected, then merge the behavioral changes, which we can revert independently.

Generally agree, but here we can't really only migrate the data, without migrating all the other code paths as well. Otherwise the migrated data itself will grow stale, if someone changes the old data through the unchanged interface... 🤔

The scaffolding needed to support that will turn complex itself.

@mariusandra mariusandra requested a review from yakkomajuri May 18, 2022 19:36
@mariusandra
Copy link
Collaborator Author

Ready for a review again.

Copy link
Contributor

@macobo macobo left a comment

Choose a reason for hiding this comment

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

Added some comments for the python/frontend side of things.

Is it at all possible to separate out the non-plugin-server changes on this? As-is if this introduces a subtle bug this is not at all revertable, which is a no-go for the plugin-server.

response[source.filename] = source.source

# Update values from plugin.json
plugin_json = json.loads(response.get("plugin.json") or "{}")
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
plugin_json = json.loads(response.get("plugin.json") or "{}")
plugin_json = json.loads(response.get("plugin.json", "{}"))

Relying on behavior of falsy values is generally an antipattern in python. .get() already has a default value argument.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This was explicit. The plugin.json string may contain "", and response.get("plugin.json") will then return "", while I want to get None instead

Copy link
Contributor

Choose a reason for hiding this comment

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

That's the kind of thing sure to will lead to bugs as things get refactored, especially when not covered by separate tests. Could we make the intent for "" vs None behavior more explicit via a model property or something?

if plugin_json["name"] != plugin.name:
plugin.name = plugin_json["name"]
performed_changes = True
if json.dumps(plugin_json.get("config") or []) != json.dumps(plugin.config_schema or []):
Copy link
Contributor

Choose a reason for hiding this comment

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

Same - use dict.get second argument here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Same reasoning here. plugin_json.get("config") will return None, but I want that to be comparable to [] in this line, if that's what plugin.config_schema contains

self.assertEqual(response.json(), {"index.ts": "'hello again'", "plugin.json": '{"name":"my plugin"}'})
self.assertEqual(mock_reload.call_count, 3)

response = self.client.patch(
Copy link
Contributor

Choose a reason for hiding this comment

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

Q: Why do we have a test that tests N different scenarios? This makes for a hard to understand and debug test suite. Could we give each scenario a name and test separately?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I decided against that initially to avoid needless scaffolding. The test essentially sets up a story, and then tests based on that story, in 5 steps. I can change maximally optimise this to be three tests with 3, 4 and 4 steps respectively. This seemed wasteful, so I opted to have one 5-step story test the entire flow.

I added helpful hints to make it easier to follow, but can break it up if you think this makes sense.


plugin: models.ForeignKey = models.ForeignKey("Plugin", on_delete=models.CASCADE)
filename: models.CharField = models.CharField(max_length=200, blank=False)
source: models.TextField = models.TextField(blank=True, null=True)
Copy link
Contributor

Choose a reason for hiding this comment

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

Why can this be null?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

When the source is actually in a .zip file, and we use this model to cache the transpiled output.

Copy link
Contributor

Choose a reason for hiding this comment

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

Let's add a comment to that effect.

@mariusandra
Copy link
Collaborator Author

Thanks for the review @macobo. Left some answers, not sure if good answers or not.

Is it at all possible to separate out the non-plugin-server changes on this? As-is if this introduces a subtle bug this is not at all revertable, which is a no-go for the plugin-server.

Everything's possible, but I'm worried about the complexity this brings. Right now, we:

  1. Create a new model and copy over the data
  2. Start using the new model immediately
  3. Profit

If I split the PRs for data + code/UI,

  1. Create a new model and copy over the data
  2. Keep using the old model, and watch the new data go ever more stale
  3. Finally migrate the UI, hoping the old copied data is still valid.

I could of course move the "copy the data" from point 1 into a second migration in the second PR, but this all seems... excessive.

So not sure how to proceed. 🤔

@macobo
Copy link
Contributor

macobo commented May 19, 2022

Goal: Make it so we can roll back if something goes wrong in the plugin server.

Proposal: Scope down the plugin-server.ts change to db.ts only where you now do a JOIN but assume that there's only a single file. Then a follow-up PR does the rest of the vm and other changes.

db.ts change should be relatively safer than vm changes so we achieve our goal of rollbackability in the most critical area.

@mariusandra
Copy link
Collaborator Author

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.

3 participants