Skip to content

Commit

Permalink
feat: Proxy as a Service (#22292)
Browse files Browse the repository at this point in the history
* frontend form

* list / create records

* move settings

* fix types

* add fields

* new fields & delete

* better states

* Update UI snapshots for `chromium` (1)

* basic task

* background task

* Hide proxy settings behind feature flag

* text color

* Update UI snapshots for `chromium` (1)

* Squash migrations

* Add created_at/update_at/created_by

* form complete state

* move env vars

* cleanup

* Use env var for target cname

* call create in celery task

* fix dns resolver import

* frontend polling

* order queryset

* tweaks

* Only save on change

* Update UI snapshots for `chromium` (1)

* only create proxy task if is_cloud

* hide settings if not cloud

* Update query snapshots

* Update UI snapshots for `chromium` (1)

* cloud or dev

* spacing

* deleting

* save docker images

* add final status

* update manifest

* Update UI snapshots for `chromium` (1)

* Update UI snapshots for `chromium` (2)

* Update UI snapshots for `chromium` (1)

* Update UI snapshots for `chromium` (2)

* fix celery task

* Fix org_id on proxy record

* isDomain not isUrl

* fix destroy kwargs

* fix help text

* domain check

* frontend form

* list / create records

* move settings

* fix types

* add fields

* new fields & delete

* better states

* Update UI snapshots for `chromium` (1)

* basic task

* background task

* text color

* Hide proxy settings behind feature flag

* form complete state

* Update UI snapshots for `chromium` (1)

* Squash migrations

* Add created_at/update_at/created_by

* move env vars

* cleanup

* frontend polling

* Use env var for target cname

* call create in celery task

* fix dns resolver import

* order queryset

* hide settings if not cloud

* tweaks

* Only save on change

* Update UI snapshots for `chromium` (1)

* only create proxy task if is_cloud

* cloud or dev

* Update query snapshots

* Update UI snapshots for `chromium` (1)

* spacing

* deleting

* add final status

* save docker images

* update manifest

* Update UI snapshots for `chromium` (1)

* Update UI snapshots for `chromium` (2)

* Update UI snapshots for `chromium` (1)

* Update UI snapshots for `chromium` (2)

* fix celery task

* Fix org_id on proxy record

* isDomain not isUrl

* domain check

* fix destroy kwargs

* fix help text

* Update UI snapshots for `chromium` (2)

* Update UI snapshots for `chromium` (1)

* Update UI snapshots for `chromium` (2)

* Update UI snapshots for `chromium` (1)

* fix code quality issues

* code quality

* fix merge

* spinner spacing

* remove unused selectors

* Update UI snapshots for `chromium` (1)

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Frank Hamand <frankhamand@gmail.com>
  • Loading branch information
3 people committed May 15, 2024
1 parent de0b78b commit de69ab8
Show file tree
Hide file tree
Showing 20 changed files with 479 additions and 1 deletion.
1 change: 1 addition & 0 deletions .github/workflows/container-images-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jobs:
uses: ./.github/actions/build-n-cache-image
with:
actions-id-token-request-url: ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }}
save: true

deploy_preview:
name: Deploy preview environment
Expand Down
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export const FEATURE_FLAGS = {
SESSION_TABLE_PROPERTY_FILTERS: 'session-table-property-filters', // owner: @robbie-c
SESSION_REPLAY_HOG_QL_FILTERING: 'session-replay-hogql-filtering', // owner: #team-replay
SESSION_REPLAY_ARTIFICIAL_LAG: 'artificial-lag-query-performance', // owner: #team-replay
PROXY_AS_A_SERVICE: 'proxy-as-a-service', // owner: #team-infrastructure
} as const
export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]

Expand Down
14 changes: 14 additions & 0 deletions frontend/src/scenes/settings/SettingsMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
ProjectVariables,
WebSnippet,
} from './project/ProjectSettings'
import { Proxy } from './project/Proxy'
import {
NetworkCaptureSettings,
ReplayAISettings,
Expand Down Expand Up @@ -317,6 +318,19 @@ export const SettingsMap: SettingSection[] = [
},
],
},
{
level: 'organization',
id: 'organization-proxy',
title: 'Proxy',
flag: 'PROXY_AS_A_SERVICE',
settings: [
{
id: 'organization-proxy',
title: 'Proxy',
component: <Proxy />,
},
],
},
{
level: 'organization',
id: 'organization-danger-zone',
Expand Down
142 changes: 142 additions & 0 deletions frontend/src/scenes/settings/project/Proxy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { IconEllipsis, IconPlus } from '@posthog/icons'
import {
LemonBanner,
LemonButton,
LemonInput,
LemonMenu,
LemonTable,
LemonTableColumns,
Spinner,
} from '@posthog/lemon-ui'
import clsx from 'clsx'
import { useActions, useValues } from 'kea'
import { Form } from 'kea-forms'
import { CodeSnippet, Language } from 'lib/components/CodeSnippet'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'

import { proxyLogic, ProxyRecord } from './proxyLogic'

export function Proxy(): JSX.Element {
const { isCloudOrDev } = useValues(preflightLogic)
const { formState, proxyRecords } = useValues(proxyLogic)
const { showForm, deleteRecord } = useActions(proxyLogic)

if (!isCloudOrDev) {
return <LemonBanner type="warning">Using a reverse proxy only works in PostHog Cloud</LemonBanner>
}

const columns: LemonTableColumns<ProxyRecord> = [
{
title: 'Domain',
dataIndex: 'domain',
},
{
title: 'Status',
dataIndex: 'status',
render: function RenderStatus(status) {
return (
<div className="space-x-1">
{status === 'issuing' && <Spinner />}
<span
className={clsx(
'capitalize',
status === 'valid'
? 'text-success'
: status == 'erroring'
? 'text-danger'
: 'text-warning'
)}
>
{status}
</span>
</div>
)
},
},
{
title: 'Actions',
render: function Render(_, { id, status }) {
return (
status != 'deleting' && (
<LemonMenu
items={[
{
label: 'Delete',
status: 'danger',
onClick: () => deleteRecord(id),
},
]}
>
<LemonButton size="xsmall" icon={<IconEllipsis />} />
</LemonMenu>
)
)
},
},
]

return (
<div className="space-y-2">
<LemonTable columns={columns} dataSource={proxyRecords} />
{formState === 'collapsed' ? (
<LemonButton onClick={showForm} type="secondary" icon={<IconPlus />}>
Add domain
</LemonButton>
) : (
<CreateRecordForm />
)}
</div>
)
}

function CreateRecordForm(): JSX.Element {
const { formState, proxyRecordsLoading } = useValues(proxyLogic)
const { collapseForm } = useActions(proxyLogic)

return (
<div className="bg-bg-light rounded border p-2 space-y-2">
{formState == 'active' ? (
<Form logic={proxyLogic} formKey="createRecord" enableFormOnSubmit className="w-full space-y-2">
<LemonField name="domain">
<LemonInput
autoFocus
placeholder="Enter a domain (e.g. ph.mydomain.com)"
data-attr="domain-input"
/>
</LemonField>
<div className="flex justify-end gap-2">
<LemonButton
type="secondary"
onClick={collapseForm}
disabledReason={proxyRecordsLoading ? 'Saving' : undefined}
>
Cancel
</LemonButton>
<LemonButton
htmlType="submit"
type="primary"
data-attr="domain-save"
loading={proxyRecordsLoading}
>
Add
</LemonButton>
</div>
</Form>
) : (
<>
<div className="text-xl font-semibold leading-tight">Almost there</div>
<div>
You need to set the <b>CNAME</b> record on your DNS provider:
</div>
<CodeSnippet language={Language.HTTP}>sdfghgfdsdfghgfdsw.com</CodeSnippet>
<div className="flex justify-end">
<LemonButton onClick={collapseForm} type="primary">
Done
</LemonButton>
</div>
</>
)}
</div>
)
}
91 changes: 91 additions & 0 deletions frontend/src/scenes/settings/project/proxyLogic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { actions, afterMount, beforeUnmount, connect, kea, listeners, path, reducers } from 'kea'
import { forms } from 'kea-forms'
import { loaders } from 'kea-loaders'
import api from 'lib/api'
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
import { isDomain } from 'lib/utils'
import { organizationLogic } from 'scenes/organizationLogic'

import type { proxyLogicType } from './proxyLogicType'

export type ProxyRecord = {
id: string
domain: string
status: 'waiting' | 'issuing' | 'valid' | 'erroring' | 'deleting'
cname_target: string
}

export type FormState = 'collapsed' | 'active' | 'complete'

export const proxyLogic = kea<proxyLogicType>([
path(['scenes', 'project', 'Settings', 'proxyLogic']),
connect({ values: [organizationLogic, ['currentOrganization']] }),
actions(() => ({
collapseForm: true,
showForm: true,
completeForm: true,
})),
reducers(() => ({
formState: [
'collapsed' as FormState,
{ showForm: () => 'active', collapseForm: () => 'collapsed', completeForm: () => 'complete' },
],
})),
loaders(({ values, actions }) => ({
proxyRecords: {
__default: [] as ProxyRecord[],
loadRecords: async () => {
return await api.get(`api/organizations/${values.currentOrganization?.id}/proxy_records`)
},
createRecord: async ({ domain }: { domain: string }) => {
const response = await api.create(`api/organizations/${values.currentOrganization?.id}/proxy_records`, {
domain,
})
lemonToast.success('Record created')
actions.completeForm()
return response
},
deleteRecord: async (id: ProxyRecord['id']) => {
const response = await api.delete(
`api/organizations/${values.currentOrganization?.id}/proxy_records/${id}`
)
return response
},
},
})),
listeners(({ actions, values, cache }) => ({
collapseForm: () => actions.loadRecords(),
deleteRecordFailure: () => actions.loadRecords(),
loadRecordsSuccess: () => {
const shouldRefresh = values.proxyRecords.some((r) => ['waiting', 'issuing', 'deleting'].includes(r.status))
if (shouldRefresh) {
cache.refreshTimeout = setTimeout(() => {
actions.loadRecords()
}, 5000)
}
},
})),
forms(({ actions }) => ({
createRecord: {
defaults: { domain: '' },
errors: ({ domain }: { domain: string }) => ({
domain: !isDomain('http://' + domain)
? 'Do not include the protocol e.g. https://'
: domain.includes('*')
? 'Domains cannot include wildcards'
: undefined,
}),
submit: ({ domain }) => {
actions.createRecord({ domain })
},
},
})),
afterMount(({ actions }) => {
actions.loadRecords()
}),
beforeUnmount(({ cache }) => {
if (cache.refreshTimeout) {
clearTimeout(cache.refreshTimeout)
}
}),
])
2 changes: 2 additions & 0 deletions frontend/src/scenes/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type SettingSectionId =
| 'organization-members'
| 'organization-authentication'
| 'organization-rbac'
| 'organization-proxy'
| 'organization-danger-zone'
| 'user-profile'
| 'user-api-keys'
Expand Down Expand Up @@ -68,6 +69,7 @@ export type SettingId =
| 'authentication-domains'
| 'organization-rbac'
| 'organization-delete'
| 'organization-proxy'
| 'details'
| 'change-password'
| '2fa'
Expand Down
2 changes: 1 addition & 1 deletion latest_migrations.manifest
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ contenttypes: 0002_remove_content_type_name
ee: 0016_rolemembership_organization_member
otp_static: 0002_throttling
otp_totp: 0002_auto_20190420_0723
posthog: 0411_eventproperty_indexes
posthog: 0412_proxyrecord
sessions: 0001_initial
social_django: 0010_uid_db_index
two_factor: 0007_auto_20201201_1019
7 changes: 7 additions & 0 deletions posthog/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
plugin,
plugin_log_entry,
property_definition,
proxy_record,
query,
search,
scheduled_change,
Expand Down Expand Up @@ -251,6 +252,12 @@ def api_not_found(request):
"organization_domains",
["organization_id"],
)
organizations_router.register(
r"proxy_records",
proxy_record.ProxyRecordViewset,
"proxy_records",
["organization_id"],
)
organizations_router.register(
r"feature_flags",
organization_feature_flag.OrganizationFeatureFlagView,
Expand Down
57 changes: 57 additions & 0 deletions posthog/api/proxy_record.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from django.conf import settings
from rest_framework import serializers
from rest_framework.viewsets import ModelViewSet

from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.models import ProxyRecord
from posthog.permissions import OrganizationAdminWritePermissions
from rest_framework.response import Response


class ProxyRecordSerializer(serializers.ModelSerializer):
class Meta:
model = ProxyRecord
fields = (
"id",
"domain",
"target_cname",
"status",
"created_at",
"updated_at",
"created_by",
)
read_only_fields = ("target_cname", "created_at", "created_by", "status")


class ProxyRecordViewset(TeamAndOrgViewSetMixin, ModelViewSet):
scope_object = "organization"
serializer_class = ProxyRecordSerializer
permission_classes = [OrganizationAdminWritePermissions]

def list(self, request, *args, **kwargs):
queryset = self.organization.proxy_records.order_by("-created_at")
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)

def create(self, request, *args, **kwargs):
domain = request.data.get("domain")
queryset = self.organization.proxy_records.order_by("-created_at")
queryset.create(
organization_id=self.organization.id,
created_by=request.user,
domain=domain,
target_cname=settings.PROXY_TARGET_CNAME,
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)

def destroy(self, request, *args, pk=None, **kwargs):
queryset = self.organization.proxy_records.order_by("-created_at")
record = queryset.get(id=pk)

if record:
record.status = ProxyRecord.Status.DELETING
record.save()

serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
2 changes: 2 additions & 0 deletions posthog/api/test/__snapshots__/test_api_docs.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
'/home/runner/work/posthog/posthog/posthog/api/organization.py: Warning [OrganizationViewSet > OrganizationSerializer]: unable to resolve type hint for function "get_member_count". Consider using a type hint or @extend_schema_field. Defaulting to string.',
'/home/runner/work/posthog/posthog/posthog/batch_exports/http.py: Warning [BatchExportOrganizationViewSet]: could not derive type of path parameter "organization_id" because model "posthog.batch_exports.models.BatchExport" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/posthog/batch_exports/http.py: Warning [BatchExportOrganizationViewSet > BatchExportSerializer]: could not resolve serializer field "HogQLSelectQueryField(required=False)". Defaulting to "string"',
'/home/runner/work/posthog/posthog/posthog/api/proxy_record.py: Warning [ProxyRecordViewset]: could not derive type of path parameter "organization_id" because it is untyped and obtaining queryset from the viewset failed. Consider adding a type to the path (e.g. <int:organization_id>) or annotating the parameter type with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/posthog/api/proxy_record.py: Warning [ProxyRecordViewset]: could not derive type of path parameter "id" because it is untyped and obtaining queryset from the viewset failed. Consider adding a type to the path (e.g. <int:id>) or annotating the parameter type with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/ee/api/role.py: Warning [RoleViewSet > RoleSerializer]: unable to resolve type hint for function "get_members". Consider using a type hint or @extend_schema_field. Defaulting to string.',
'/home/runner/work/posthog/posthog/ee/api/role.py: Warning [RoleViewSet > RoleSerializer]: unable to resolve type hint for function "get_associated_flags". Consider using a type hint or @extend_schema_field. Defaulting to string.',
'/home/runner/work/posthog/posthog/ee/api/role.py: Warning [RoleMembershipViewSet]: could not derive type of path parameter "organization_id" because model "ee.models.role.RoleMembership" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
Expand Down

0 comments on commit de69ab8

Please sign in to comment.