From b65576f8a04b57f69d694e430283eee46fa88bfb Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 16 Sep 2025 15:32:28 +0200 Subject: [PATCH] feat: native composition report (#7007) --- codegen.mts | 4 +- .../api/src/modules/auth/lib/authz.ts | 12 +- .../modules/auth/lib/supertokens-strategy.ts | 12 ++ .../project/providers/project-manager.ts | 1 + .../api/src/modules/schema/module.graphql.ts | 30 +++- .../schema/providers/schema-manager.ts | 152 +++++++++--------- packages/services/api/src/shared/entities.ts | 2 +- .../project/settings/native-composition.tsx | 50 +++--- .../app/src/pages/native-composition-diff.tsx | 123 ++++++++++++++ packages/web/app/src/router.tsx | 11 ++ 10 files changed, 294 insertions(+), 103 deletions(-) create mode 100644 packages/web/app/src/pages/native-composition-diff.tsx diff --git a/codegen.mts b/codegen.mts index 6adbbfaea..65c67fd70 100644 --- a/codegen.mts +++ b/codegen.mts @@ -34,8 +34,8 @@ const config: CodegenConfig = { contextType: 'GraphQLModules.ModuleContext', enumValues: { ProjectType: '../shared/entities#ProjectType', - NativeFederationCompatibilityStatus: - '../shared/entities#NativeFederationCompatibilityStatus', + NativeFederationCompatibilityStatusType: + '../shared/entities#NativeFederationCompatibilityStatusType', TargetAccessScope: '../modules/auth/providers/scopes#TargetAccessScope', ProjectAccessScope: '../modules/auth/providers/scopes#ProjectAccessScope', OrganizationAccessScope: '../modules/auth/providers/scopes#OrganizationAccessScope', diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index 0e6163269..faa12454d 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -281,12 +281,13 @@ function isActionMatch(actionContainingWildcard: string, action: string) { if (actionContainingWildcard === '*') { return true; } + // exact match if (actionContainingWildcard === action) { return true; } - const [actionScope] = action.split(':'); + const [actionScope, actionId] = action.split(':'); const [userSpecifiedActionScope, userSpecifiedActionId] = actionContainingWildcard.split(':'); // wildcard match "scope:*" @@ -294,6 +295,11 @@ function isActionMatch(actionContainingWildcard: string, action: string) { return true; } + // wildcard match "*:scope" + if (userSpecifiedActionScope === '*' && userSpecifiedActionId === actionId) { + return true; + } + return false; } @@ -500,7 +506,7 @@ type ActionDefinitionMap = { [key: `${string}:${string}`]: (args: any) => Array; }; -const actionDefinitions = { +export const actionDefinitions = { ...objectFromEntries(permissionsByLevel['organization'].map(t => [t.value, defaultOrgIdentity])), ...objectFromEntries(permissionsByLevel['project'].map(t => [t.value, defaultProjectIdentity])), ...objectFromEntries(permissionsByLevel['target'].map(t => [t.value, defaultTargetIdentity])), @@ -514,7 +520,7 @@ const actionDefinitions = { type Actions = keyof typeof actionDefinitions; -type ActionStrings = Actions | '*'; +type ActionStrings = Actions | '*' | '*:describe'; /** Unauthenticated session that is returned by default. */ class UnauthenticatedSession extends Session { diff --git a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts index 83686316a..11e6d609a 100644 --- a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts @@ -67,6 +67,18 @@ export class SuperTokensCookieBasedSession extends Session { organizationId, ); + // Allow admins to use all describe actions within foreign organizations + // This makes it much more pleasant to debug. + if (user.isAdmin) { + return [ + { + action: '*:describe', + effect: 'allow', + resource: `hrn:${organizationId}:organization/${organizationId}`, + }, + ]; + } + return []; } diff --git a/packages/services/api/src/modules/project/providers/project-manager.ts b/packages/services/api/src/modules/project/providers/project-manager.ts index 42397b4a5..db2f22066 100644 --- a/packages/services/api/src/modules/project/providers/project-manager.ts +++ b/packages/services/api/src/modules/project/providers/project-manager.ts @@ -198,6 +198,7 @@ export class ProjectManager { projectId: selector.projectId, }, }); + return this.storage.getProject(selector); } diff --git a/packages/services/api/src/modules/schema/module.graphql.ts b/packages/services/api/src/modules/schema/module.graphql.ts index 50e4369ee..080a2cd73 100644 --- a/packages/services/api/src/modules/schema/module.graphql.ts +++ b/packages/services/api/src/modules/schema/module.graphql.ts @@ -114,14 +114,40 @@ export default gql` externalSchemaComposition: ExternalSchemaComposition schemaVersionsCount(period: DateRangeInput): Int! isNativeFederationEnabled: Boolean! - nativeFederationCompatibility: NativeFederationCompatibilityStatus! + """ + Get the status of the native federation compatability for the project. + """ + nativeFederationCompatibility: NativeCompositionCompatibility! } extend type Target { schemaVersionsCount(period: DateRangeInput): Int! } - enum NativeFederationCompatibilityStatus { + type NativeCompositionVersionStatus { + """ + The schema version we check against. + """ + schemaVersion: SchemaVersion! + """ + The native composition result. The supergraphSdl is sorted and normalized. + """ + nativeCompositionResult: SchemaCompositionResult! + """ + The supergraph of the latest valid schema version (sorted and normalized). + """ + currentSupergraphSdl: String! + } + + type NativeCompositionCompatibility { + """ + Whether the schema version is compatible. + """ + status: NativeFederationCompatibilityStatusType! + results: [NativeCompositionVersionStatus]! + } + + enum NativeFederationCompatibilityStatusType { COMPATIBLE INCOMPATIBLE UNKNOWN diff --git a/packages/services/api/src/modules/schema/providers/schema-manager.ts b/packages/services/api/src/modules/schema/providers/schema-manager.ts index 8bd2bf5b7..cb8c6cd64 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -16,7 +16,7 @@ import { SchemaChecksFilter } from '../../../__generated__/types'; import * as GraphQLSchema from '../../../__generated__/types'; import { DateRange, - NativeFederationCompatibilityStatus, + NativeFederationCompatibilityStatusType, Organization, Project, ProjectType, @@ -1123,7 +1123,18 @@ export class SchemaManager { return true; } - async getNativeFederationCompatibilityStatus(project: Project) { + async getNativeFederationCompatibilityStatus(project: Project): Promise<{ + status: NativeFederationCompatibilityStatusType; + results: Array | null; + }; + currentSupergraphSdl: string; + }>; + }> { this.logger.debug( 'Get native Federation compatibility status (organization=%s, project=%s)', project.orgId, @@ -1131,7 +1142,10 @@ export class SchemaManager { ); if (project.type !== ProjectType.FEDERATION) { - return NativeFederationCompatibilityStatus.NOT_APPLICABLE; + return { + status: NativeFederationCompatibilityStatusType.NOT_APPLICABLE, + results: [], + }; } const targets = await this.targetManager.getTargets({ @@ -1139,48 +1153,39 @@ export class SchemaManager { projectId: project.id, }); - const possibleVersions = await Promise.all( - targets.map(target => this.getMaybeLatestValidVersion(target)), - ); + const results = await Promise.all( + targets.map(async target => { + const schemaVersion = await this.getMaybeLatestValidVersion(target); - const versions = possibleVersions.filter((v): v is SchemaVersion => !!v); + if (schemaVersion === null) { + return null; + } - this.logger.debug('Found %s targets and %s versions', targets.length, versions.length); + const currentSupergraphSdl = print( + removeDescriptions( + sortSDL( + parseGraphQLSource( + schemaVersion.supergraphSDL!, + 'parsing native supergraph in getNativeFederationCompatibilityStatus', + ), + ), + ), + ); - // If there are no composable versions available, we can't determine the compatibility status. - if ( - versions.length === 0 || - !versions.every( - version => version && version.isComposable && typeof version.supergraphSDL === 'string', - ) - ) { - this.logger.debug('No composable versions available (status: unknown)'); - return NativeFederationCompatibilityStatus.UNKNOWN; - } + const schemas = await this.getSchemasOfVersion({ + organizationId: target.orgId, + projectId: target.projectId, + targetId: target.id, + versionId: schemaVersion.id, + }); - const schemasPerVersion = await Promise.all( - versions.map(async version => - this.getSchemasOfVersion({ - organizationId: version.organizationId, - projectId: version.projectId, - targetId: version.targetId, - versionId: version.id, - }), - ), - ); - - this.logger.debug('Checking compatibility of %s versions', versions.length); - - const compatibilityResults = await Promise.all( - versions.map(async (version, i) => { - if (schemasPerVersion[i].length === 0) { - this.logger.debug('No schemas (version=%s)', version.id); - return NativeFederationCompatibilityStatus.UNKNOWN; + if (schemas.length === 0) { + return null; } const compositionResult = await this.compositionOrchestrator.composeAndValidate( 'federation', - ensureCompositeSchemas(schemasPerVersion[i]).map(s => + ensureCompositeSchemas(schemas).map(s => this.schemaHelper.createSchemaObject({ sdl: s.sdl, service_name: s.service_name, @@ -1196,53 +1201,52 @@ export class SchemaManager { }, ); - if (compositionResult.supergraph) { - const sortedExistingSupergraph = print( - removeDescriptions( - sortSDL( - parseGraphQLSource( - compositionResult.supergraph, - 'parsing existing supergraph in getNativeFederationCompatibilityStatus', + const supergraphSdl = compositionResult.supergraph + ? print( + removeDescriptions( + sortSDL( + parseGraphQLSource( + compositionResult.supergraph, + 'parsing native supergraph in getNativeFederationCompatibilityStatus', + ), ), ), - ), - ); - const sortedNativeSupergraph = print( - removeDescriptions( - sortSDL( - parseGraphQLSource( - version.supergraphSDL!, - 'parsing native supergraph in getNativeFederationCompatibilityStatus', - ), - ), - ), - ); + ) + : null; - if (sortedNativeSupergraph === sortedExistingSupergraph) { - return NativeFederationCompatibilityStatus.COMPATIBLE; - } - - this.logger.debug('Produced different supergraph (version=%s)', version.id); - } else { - this.logger.debug('Failed to produce supergraph (version=%s)', version.id); - } - - return NativeFederationCompatibilityStatus.INCOMPATIBLE; + return { + target, + schemaVersion, + currentSupergraphSdl, + nativeCompositionResult: { + supergraphSdl, + errors: compositionResult.errors, + }, + }; }), ); - if (compatibilityResults.includes(NativeFederationCompatibilityStatus.UNKNOWN)) { - this.logger.debug('One of the versions seems empty (status: unknown)'); - return NativeFederationCompatibilityStatus.UNKNOWN; - } + let status = NativeFederationCompatibilityStatusType.INCOMPATIBLE; - if (compatibilityResults.every(r => r === NativeFederationCompatibilityStatus.COMPATIBLE)) { + if (results.every(result => result === null)) { + this.logger.debug('No composable versions available (status: unknown)'); + status = NativeFederationCompatibilityStatusType.UNKNOWN; + } else if ( + results.every( + result => + result === null || + (result.nativeCompositionResult && + result.currentSupergraphSdl === result.nativeCompositionResult.supergraphSdl), + ) + ) { this.logger.debug('All versions are compatible (status: compatible)'); - return NativeFederationCompatibilityStatus.COMPATIBLE; + status = NativeFederationCompatibilityStatusType.COMPATIBLE; } - this.logger.debug('Some versions are incompatible (status: incompatible)'); - return NativeFederationCompatibilityStatus.INCOMPATIBLE; + return { + status, + results, + }; } async getGitHubMetadata(schemaVersion: SchemaVersion): Promise
- {projectQuery.data.project.nativeFederationCompatibility === - NativeFederationCompatibilityStatus.Compatible ? ( + {projectQuery.data.project.nativeFederationCompatibility.status === + NativeFederationCompatibilityStatusType.Compatible ? ( ) : null} - {projectQuery.data.project.nativeFederationCompatibility === - NativeFederationCompatibilityStatus.Incompatible ? ( + {projectQuery.data.project.nativeFederationCompatibility.status === + NativeFederationCompatibilityStatusType.Incompatible ? ( ) : null} - {projectQuery.data.project.nativeFederationCompatibility === - NativeFederationCompatibilityStatus.Unknown ? ( + {projectQuery.data.project.nativeFederationCompatibility.status === + NativeFederationCompatibilityStatusType.Unknown ? ( ) : null}
- {projectQuery.data.project.nativeFederationCompatibility === - NativeFederationCompatibilityStatus.Compatible + {projectQuery.data.project.nativeFederationCompatibility.status === + NativeFederationCompatibilityStatusType.Compatible ? 'Your project is compatible' : null} - {projectQuery.data.project.nativeFederationCompatibility === - NativeFederationCompatibilityStatus.Incompatible + {projectQuery.data.project.nativeFederationCompatibility.status === + NativeFederationCompatibilityStatusType.Incompatible ? 'Your project is not yet supported' : null} - {projectQuery.data.project.nativeFederationCompatibility === - NativeFederationCompatibilityStatus.Unknown + {projectQuery.data.project.nativeFederationCompatibility.status === + NativeFederationCompatibilityStatusType.Unknown ? 'Unclear whether your project is compatible' : null}
- {projectQuery.data.project.nativeFederationCompatibility === - NativeFederationCompatibilityStatus.Compatible ? ( + {projectQuery.data.project.nativeFederationCompatibility.status === + NativeFederationCompatibilityStatusType.Compatible ? ( <> Subgraphs of this project are composed and validated correctly by our{' '} ) : null} - {projectQuery.data.project.nativeFederationCompatibility === - NativeFederationCompatibilityStatus.Incompatible ? ( + {projectQuery.data.project.nativeFederationCompatibility.status === + NativeFederationCompatibilityStatusType.Incompatible ? ( <> Our{' '} {' '} is not yet compatible with subgraphs of your project. We're working on it!
- Please reach out to us to explore solutions for addressing this issue. + Please reach out to us to explore solutions for addressing this issue and share + this report with us: +
+ View full report + ) : null} - {projectQuery.data.project.nativeFederationCompatibility === - NativeFederationCompatibilityStatus.Unknown ? ( + {projectQuery.data.project.nativeFederationCompatibility.status === + NativeFederationCompatibilityStatusType.Unknown ? ( <> Your project appears to lack any subgraphs at the moment, making it impossible for us to assess compatibility with our{' '} diff --git a/packages/web/app/src/pages/native-composition-diff.tsx b/packages/web/app/src/pages/native-composition-diff.tsx new file mode 100644 index 000000000..741071e42 --- /dev/null +++ b/packages/web/app/src/pages/native-composition-diff.tsx @@ -0,0 +1,123 @@ +import { ReactNode } from 'react'; +import { useQuery } from 'urql'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { DiffEditor } from '@/components/v2'; +import { graphql } from '@/gql'; +import { useClipboard } from '@/lib/hooks'; + +type NativeCompositionDiffProps = { + projectId: string; +}; + +const NativeCompositionDiff_NativeCompositionDiffQuery = graphql(/* GraphQL */ ` + query NativeCompositionDiff_NativeCompositionDiffQuery($projectId: ID!) { + project(reference: { byId: $projectId }) { + id + nativeFederationCompatibility { + status + results { + currentSupergraphSdl + nativeCompositionResult { + supergraphSdl + errors { + edges { + node { + message + } + } + } + } + schemaVersion { + id + schemas { + edges { + node { + ... on CompositeSchema { + id + service + source + } + } + } + } + } + } + } + } + } +`); + +export function NativeCompositionDiff(props: NativeCompositionDiffProps): ReactNode | null { + const [result] = useQuery({ + query: NativeCompositionDiff_NativeCompositionDiffQuery, + variables: { + projectId: props.projectId, + }, + }); + + const clipboard = useClipboard(); + + if (!result.data?.project?.nativeFederationCompatibility) { + return null; + } + + const { nativeFederationCompatibility } = result.data.project; + + return ( + <> +

Native Composition Report Project {result.data.project.id}

+

Status: {nativeFederationCompatibility.status}

+ + + {nativeFederationCompatibility.results.map((result, index) => + result ? Target {index} : null, + )} + + {nativeFederationCompatibility.results.map((result, index) => + result ? ( + +
+
Supergraph Diff
+
+ +
+
+
+
Composition Errors:
+
+ {result.nativeCompositionResult.errors?.edges?.length ? ( +
    + {result.nativeCompositionResult.errors.edges.map(edge => ( +
  • {edge.node.message}
  • + ))} +
+ ) : ( + 'None' + )} +
+
+
+ +
+
+ ) : null, + )} +
+ + ); +} diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx index cd018fb86..e7d6d329d 100644 --- a/packages/web/app/src/router.tsx +++ b/packages/web/app/src/router.tsx @@ -38,6 +38,7 @@ import { DevPage } from './pages/dev'; import { IndexPage } from './pages/index'; import { LogoutPage } from './pages/logout'; import { ManagePage } from './pages/manage'; +import { NativeCompositionDiff } from './pages/native-composition-diff'; import { OrganizationIndexRouteSearch, OrganizationPage } from './pages/organization'; import { JoinOrganizationPage } from './pages/organization-join'; import { OrganizationMembersPage } from './pages/organization-members'; @@ -301,6 +302,15 @@ const devRoute = createRoute({ component: DevPage, }); +const nativeCompositionDiffRoute = createRoute({ + getParentRoute: () => authenticatedRoute, + path: 'native-composition-compatibility-report/$projectId', + component: function NativeCompositionDiffRoute() { + const { projectId } = nativeCompositionDiffRoute.useParams(); + return ; + }, +}); + const newOrgPage = createRoute({ getParentRoute: () => authenticatedRoute, path: 'org/new', @@ -836,6 +846,7 @@ const routeTree = root.addChildren([ ]), authenticatedRoute.addChildren([ indexRoute, + nativeCompositionDiffRoute, devRoute, newOrgPage, manageRoute,