-
Notifications
You must be signed in to change notification settings - Fork 1.9k
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
Conversation
Q: What is the operational effect of this to plugin-server? How does it affect mean-time-to-ingest events? |
There was a problem hiding this 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 👍
frontend/src/scenes/plugins/source/createDefaultPluginSource.ts
Outdated
Show resolved
Hide resolved
@@ -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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah brain 💨
posthog/api/plugin.py
Outdated
if sources.get(key): | ||
if sources[key].source != value: | ||
performed_changes = True | ||
if value is None: |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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 That may sound like a lot (N+1 queries anyone?), but:
|
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. |
Ready for a review again. |
There was a problem hiding this 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 "{}") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 []): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
posthog/models/plugin.py
Outdated
|
||
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Thanks for the review @macobo. Left some answers, not sure if good answers or not.
Everything's possible, but I'm worried about the complexity this brings. Right now, we:
If I split the PRs for data + code/UI,
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. 🤔 |
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. |
Going to close this as well in favour of the forks: |
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
PluginSource
model for storing source for a pluginsource
field, and create a migration to move old data over. Remove the field from the serializer.PluginSource
objects, but will get all files and send all changes toplugins/:id/source
in one object{"index.ts": "code", "plugin.json": "{}"}
).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.How did you test this code?
Next steps
frontend.tsx
and transpiling support.