feat: native composition report (#7007)

This commit is contained in:
Laurin Quast 2025-09-16 15:32:28 +02:00 committed by GitHub
parent cefd6f24db
commit b65576f8a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 294 additions and 103 deletions

View file

@ -34,8 +34,8 @@ const config: CodegenConfig = {
contextType: 'GraphQLModules.ModuleContext', contextType: 'GraphQLModules.ModuleContext',
enumValues: { enumValues: {
ProjectType: '../shared/entities#ProjectType', ProjectType: '../shared/entities#ProjectType',
NativeFederationCompatibilityStatus: NativeFederationCompatibilityStatusType:
'../shared/entities#NativeFederationCompatibilityStatus', '../shared/entities#NativeFederationCompatibilityStatusType',
TargetAccessScope: '../modules/auth/providers/scopes#TargetAccessScope', TargetAccessScope: '../modules/auth/providers/scopes#TargetAccessScope',
ProjectAccessScope: '../modules/auth/providers/scopes#ProjectAccessScope', ProjectAccessScope: '../modules/auth/providers/scopes#ProjectAccessScope',
OrganizationAccessScope: '../modules/auth/providers/scopes#OrganizationAccessScope', OrganizationAccessScope: '../modules/auth/providers/scopes#OrganizationAccessScope',

View file

@ -281,12 +281,13 @@ function isActionMatch(actionContainingWildcard: string, action: string) {
if (actionContainingWildcard === '*') { if (actionContainingWildcard === '*') {
return true; return true;
} }
// exact match // exact match
if (actionContainingWildcard === action) { if (actionContainingWildcard === action) {
return true; return true;
} }
const [actionScope] = action.split(':'); const [actionScope, actionId] = action.split(':');
const [userSpecifiedActionScope, userSpecifiedActionId] = actionContainingWildcard.split(':'); const [userSpecifiedActionScope, userSpecifiedActionId] = actionContainingWildcard.split(':');
// wildcard match "scope:*" // wildcard match "scope:*"
@ -294,6 +295,11 @@ function isActionMatch(actionContainingWildcard: string, action: string) {
return true; return true;
} }
// wildcard match "*:scope"
if (userSpecifiedActionScope === '*' && userSpecifiedActionId === actionId) {
return true;
}
return false; return false;
} }
@ -500,7 +506,7 @@ type ActionDefinitionMap = {
[key: `${string}:${string}`]: (args: any) => Array<string>; [key: `${string}:${string}`]: (args: any) => Array<string>;
}; };
const actionDefinitions = { export const actionDefinitions = {
...objectFromEntries(permissionsByLevel['organization'].map(t => [t.value, defaultOrgIdentity])), ...objectFromEntries(permissionsByLevel['organization'].map(t => [t.value, defaultOrgIdentity])),
...objectFromEntries(permissionsByLevel['project'].map(t => [t.value, defaultProjectIdentity])), ...objectFromEntries(permissionsByLevel['project'].map(t => [t.value, defaultProjectIdentity])),
...objectFromEntries(permissionsByLevel['target'].map(t => [t.value, defaultTargetIdentity])), ...objectFromEntries(permissionsByLevel['target'].map(t => [t.value, defaultTargetIdentity])),
@ -514,7 +520,7 @@ const actionDefinitions = {
type Actions = keyof typeof actionDefinitions; type Actions = keyof typeof actionDefinitions;
type ActionStrings = Actions | '*'; type ActionStrings = Actions | '*' | '*:describe';
/** Unauthenticated session that is returned by default. */ /** Unauthenticated session that is returned by default. */
class UnauthenticatedSession extends Session { class UnauthenticatedSession extends Session {

View file

@ -67,6 +67,18 @@ export class SuperTokensCookieBasedSession extends Session {
organizationId, 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 []; return [];
} }

View file

@ -198,6 +198,7 @@ export class ProjectManager {
projectId: selector.projectId, projectId: selector.projectId,
}, },
}); });
return this.storage.getProject(selector); return this.storage.getProject(selector);
} }

View file

@ -114,14 +114,40 @@ export default gql`
externalSchemaComposition: ExternalSchemaComposition externalSchemaComposition: ExternalSchemaComposition
schemaVersionsCount(period: DateRangeInput): Int! schemaVersionsCount(period: DateRangeInput): Int!
isNativeFederationEnabled: Boolean! isNativeFederationEnabled: Boolean!
nativeFederationCompatibility: NativeFederationCompatibilityStatus! """
Get the status of the native federation compatability for the project.
"""
nativeFederationCompatibility: NativeCompositionCompatibility!
} }
extend type Target { extend type Target {
schemaVersionsCount(period: DateRangeInput): Int! 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 COMPATIBLE
INCOMPATIBLE INCOMPATIBLE
UNKNOWN UNKNOWN

View file

@ -16,7 +16,7 @@ import { SchemaChecksFilter } from '../../../__generated__/types';
import * as GraphQLSchema from '../../../__generated__/types'; import * as GraphQLSchema from '../../../__generated__/types';
import { import {
DateRange, DateRange,
NativeFederationCompatibilityStatus, NativeFederationCompatibilityStatusType,
Organization, Organization,
Project, Project,
ProjectType, ProjectType,
@ -1123,7 +1123,18 @@ export class SchemaManager {
return true; return true;
} }
async getNativeFederationCompatibilityStatus(project: Project) { async getNativeFederationCompatibilityStatus(project: Project): Promise<{
status: NativeFederationCompatibilityStatusType;
results: Array<null | {
schemaVersion: SchemaVersion;
target: Target;
nativeCompositionResult: {
supergraphSdl: string | null;
errors: Array<{ message: string }> | null;
};
currentSupergraphSdl: string;
}>;
}> {
this.logger.debug( this.logger.debug(
'Get native Federation compatibility status (organization=%s, project=%s)', 'Get native Federation compatibility status (organization=%s, project=%s)',
project.orgId, project.orgId,
@ -1131,7 +1142,10 @@ export class SchemaManager {
); );
if (project.type !== ProjectType.FEDERATION) { if (project.type !== ProjectType.FEDERATION) {
return NativeFederationCompatibilityStatus.NOT_APPLICABLE; return {
status: NativeFederationCompatibilityStatusType.NOT_APPLICABLE,
results: [],
};
} }
const targets = await this.targetManager.getTargets({ const targets = await this.targetManager.getTargets({
@ -1139,48 +1153,39 @@ export class SchemaManager {
projectId: project.id, projectId: project.id,
}); });
const possibleVersions = await Promise.all( const results = await Promise.all(
targets.map(target => this.getMaybeLatestValidVersion(target)), 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. const schemas = await this.getSchemasOfVersion({
if ( organizationId: target.orgId,
versions.length === 0 || projectId: target.projectId,
!versions.every( targetId: target.id,
version => version && version.isComposable && typeof version.supergraphSDL === 'string', versionId: schemaVersion.id,
) });
) {
this.logger.debug('No composable versions available (status: unknown)');
return NativeFederationCompatibilityStatus.UNKNOWN;
}
const schemasPerVersion = await Promise.all( if (schemas.length === 0) {
versions.map(async version => return null;
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;
} }
const compositionResult = await this.compositionOrchestrator.composeAndValidate( const compositionResult = await this.compositionOrchestrator.composeAndValidate(
'federation', 'federation',
ensureCompositeSchemas(schemasPerVersion[i]).map(s => ensureCompositeSchemas(schemas).map(s =>
this.schemaHelper.createSchemaObject({ this.schemaHelper.createSchemaObject({
sdl: s.sdl, sdl: s.sdl,
service_name: s.service_name, service_name: s.service_name,
@ -1196,53 +1201,52 @@ export class SchemaManager {
}, },
); );
if (compositionResult.supergraph) { const supergraphSdl = compositionResult.supergraph
const sortedExistingSupergraph = print( ? print(
removeDescriptions( removeDescriptions(
sortSDL( sortSDL(
parseGraphQLSource( parseGraphQLSource(
compositionResult.supergraph, compositionResult.supergraph,
'parsing existing supergraph in getNativeFederationCompatibilityStatus', 'parsing native supergraph in getNativeFederationCompatibilityStatus',
),
), ),
), ),
), )
); : null;
const sortedNativeSupergraph = print(
removeDescriptions(
sortSDL(
parseGraphQLSource(
version.supergraphSDL!,
'parsing native supergraph in getNativeFederationCompatibilityStatus',
),
),
),
);
if (sortedNativeSupergraph === sortedExistingSupergraph) { return {
return NativeFederationCompatibilityStatus.COMPATIBLE; target,
} schemaVersion,
currentSupergraphSdl,
this.logger.debug('Produced different supergraph (version=%s)', version.id); nativeCompositionResult: {
} else { supergraphSdl,
this.logger.debug('Failed to produce supergraph (version=%s)', version.id); errors: compositionResult.errors,
} },
};
return NativeFederationCompatibilityStatus.INCOMPATIBLE;
}), }),
); );
if (compatibilityResults.includes(NativeFederationCompatibilityStatus.UNKNOWN)) { let status = NativeFederationCompatibilityStatusType.INCOMPATIBLE;
this.logger.debug('One of the versions seems empty (status: unknown)');
return NativeFederationCompatibilityStatus.UNKNOWN;
}
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)'); 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 {
return NativeFederationCompatibilityStatus.INCOMPATIBLE; status,
results,
};
} }
async getGitHubMetadata(schemaVersion: SchemaVersion): Promise<null | { async getGitHubMetadata(schemaVersion: SchemaVersion): Promise<null | {

View file

@ -145,7 +145,7 @@ export enum ProjectType {
SINGLE = 'SINGLE', SINGLE = 'SINGLE',
} }
export enum NativeFederationCompatibilityStatus { export enum NativeFederationCompatibilityStatusType {
COMPATIBLE = 'COMPATIBLE', COMPATIBLE = 'COMPATIBLE',
INCOMPATIBLE = 'INCOMPATIBLE', INCOMPATIBLE = 'INCOMPATIBLE',
UNKNOWN = 'UNKNOWN', UNKNOWN = 'UNKNOWN',

View file

@ -16,7 +16,7 @@ import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { FragmentType, graphql, useFragment } from '@/gql'; import { FragmentType, graphql, useFragment } from '@/gql';
import { NativeFederationCompatibilityStatus } from '@/gql/graphql'; import { NativeFederationCompatibilityStatusType } from '@/gql/graphql';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const IncrementalNativeCompositionSwitch_TargetFragment = graphql(` const IncrementalNativeCompositionSwitch_TargetFragment = graphql(`
@ -121,7 +121,9 @@ const NativeCompositionSettings_ProjectQuery = graphql(`
query NativeCompositionSettings_ProjectQuery($selector: ProjectSelectorInput!) { query NativeCompositionSettings_ProjectQuery($selector: ProjectSelectorInput!) {
project(reference: { bySelector: $selector }) { project(reference: { bySelector: $selector }) {
id id
nativeFederationCompatibility nativeFederationCompatibility {
status
}
experimental_nativeCompositionPerTarget experimental_nativeCompositionPerTarget
} }
} }
@ -290,37 +292,37 @@ export function NativeCompositionSettings(props: {
<CardContent> <CardContent>
<div className="flex flex-row items-center gap-x-4"> <div className="flex flex-row items-center gap-x-4">
<div> <div>
{projectQuery.data.project.nativeFederationCompatibility === {projectQuery.data.project.nativeFederationCompatibility.status ===
NativeFederationCompatibilityStatus.Compatible ? ( NativeFederationCompatibilityStatusType.Compatible ? (
<PartyPopperIcon className="size-10 text-emerald-500" /> <PartyPopperIcon className="size-10 text-emerald-500" />
) : null} ) : null}
{projectQuery.data.project.nativeFederationCompatibility === {projectQuery.data.project.nativeFederationCompatibility.status ===
NativeFederationCompatibilityStatus.Incompatible ? ( NativeFederationCompatibilityStatusType.Incompatible ? (
<HeartCrackIcon className="size-10 text-red-500" /> <HeartCrackIcon className="size-10 text-red-500" />
) : null} ) : null}
{projectQuery.data.project.nativeFederationCompatibility === {projectQuery.data.project.nativeFederationCompatibility.status ===
NativeFederationCompatibilityStatus.Unknown ? ( NativeFederationCompatibilityStatusType.Unknown ? (
<FlaskConicalIcon className="size-10 text-orange-500" /> <FlaskConicalIcon className="size-10 text-orange-500" />
) : null} ) : null}
</div> </div>
<div> <div>
<div className="text-base font-semibold"> <div className="text-base font-semibold">
{projectQuery.data.project.nativeFederationCompatibility === {projectQuery.data.project.nativeFederationCompatibility.status ===
NativeFederationCompatibilityStatus.Compatible NativeFederationCompatibilityStatusType.Compatible
? 'Your project is compatible' ? 'Your project is compatible'
: null} : null}
{projectQuery.data.project.nativeFederationCompatibility === {projectQuery.data.project.nativeFederationCompatibility.status ===
NativeFederationCompatibilityStatus.Incompatible NativeFederationCompatibilityStatusType.Incompatible
? 'Your project is not yet supported' ? 'Your project is not yet supported'
: null} : null}
{projectQuery.data.project.nativeFederationCompatibility === {projectQuery.data.project.nativeFederationCompatibility.status ===
NativeFederationCompatibilityStatus.Unknown NativeFederationCompatibilityStatusType.Unknown
? 'Unclear whether your project is compatible' ? 'Unclear whether your project is compatible'
: null} : null}
</div> </div>
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
{projectQuery.data.project.nativeFederationCompatibility === {projectQuery.data.project.nativeFederationCompatibility.status ===
NativeFederationCompatibilityStatus.Compatible ? ( NativeFederationCompatibilityStatusType.Compatible ? (
<> <>
Subgraphs of this project are composed and validated correctly by our{' '} Subgraphs of this project are composed and validated correctly by our{' '}
<a <a
@ -332,8 +334,8 @@ export function NativeCompositionSettings(props: {
for Apollo Federation. for Apollo Federation.
</> </>
) : null} ) : null}
{projectQuery.data.project.nativeFederationCompatibility === {projectQuery.data.project.nativeFederationCompatibility.status ===
NativeFederationCompatibilityStatus.Incompatible ? ( NativeFederationCompatibilityStatusType.Incompatible ? (
<> <>
Our{' '} Our{' '}
<a <a
@ -344,11 +346,17 @@ export function NativeCompositionSettings(props: {
</a>{' '} </a>{' '}
is not yet compatible with subgraphs of your project. We're working on it! is not yet compatible with subgraphs of your project. We're working on it!
<br /> <br />
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:
<a
href={`/native-composition-compatibility-report/${projectQuery.data.project.id}`}
>
View full report
</a>
</> </>
) : null} ) : null}
{projectQuery.data.project.nativeFederationCompatibility === {projectQuery.data.project.nativeFederationCompatibility.status ===
NativeFederationCompatibilityStatus.Unknown ? ( NativeFederationCompatibilityStatusType.Unknown ? (
<> <>
Your project appears to lack any subgraphs at the moment, making it impossible Your project appears to lack any subgraphs at the moment, making it impossible
for us to assess compatibility with our{' '} for us to assess compatibility with our{' '}

View file

@ -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 (
<>
<h1>Native Composition Report Project {result.data.project.id}</h1>
<h2>Status: {nativeFederationCompatibility.status}</h2>
<Tabs>
<TabsList>
{nativeFederationCompatibility.results.map((result, index) =>
result ? <TabsTrigger value={String(index)}>Target {index}</TabsTrigger> : null,
)}
</TabsList>
{nativeFederationCompatibility.results.map((result, index) =>
result ? (
<TabsContent value={String(index)}>
<div>
<div>Supergraph Diff</div>
<div>
<DiffEditor
before={result.currentSupergraphSdl}
after={result.nativeCompositionResult.supergraphSdl ?? null}
/>
</div>
</div>
<div>
<div>Composition Errors:</div>
<div>
{result.nativeCompositionResult.errors?.edges?.length ? (
<ul>
{result.nativeCompositionResult.errors.edges.map(edge => (
<li>{edge.node.message}</li>
))}
</ul>
) : (
'None'
)}
</div>
</div>
<div className="mb-10">
<Button
onClick={() => {
const services = result.schemaVersion?.schemas.edges.map(edge => ({
sdl: (edge.node as any).source,
name: (edge.node as any).service,
}));
void clipboard(JSON.stringify(services, null, 2));
}}
>
Copy services to clipboard
</Button>
</div>
</TabsContent>
) : null,
)}
</Tabs>
</>
);
}

View file

@ -38,6 +38,7 @@ import { DevPage } from './pages/dev';
import { IndexPage } from './pages/index'; import { IndexPage } from './pages/index';
import { LogoutPage } from './pages/logout'; import { LogoutPage } from './pages/logout';
import { ManagePage } from './pages/manage'; import { ManagePage } from './pages/manage';
import { NativeCompositionDiff } from './pages/native-composition-diff';
import { OrganizationIndexRouteSearch, OrganizationPage } from './pages/organization'; import { OrganizationIndexRouteSearch, OrganizationPage } from './pages/organization';
import { JoinOrganizationPage } from './pages/organization-join'; import { JoinOrganizationPage } from './pages/organization-join';
import { OrganizationMembersPage } from './pages/organization-members'; import { OrganizationMembersPage } from './pages/organization-members';
@ -301,6 +302,15 @@ const devRoute = createRoute({
component: DevPage, component: DevPage,
}); });
const nativeCompositionDiffRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: 'native-composition-compatibility-report/$projectId',
component: function NativeCompositionDiffRoute() {
const { projectId } = nativeCompositionDiffRoute.useParams();
return <NativeCompositionDiff projectId={projectId} />;
},
});
const newOrgPage = createRoute({ const newOrgPage = createRoute({
getParentRoute: () => authenticatedRoute, getParentRoute: () => authenticatedRoute,
path: 'org/new', path: 'org/new',
@ -836,6 +846,7 @@ const routeTree = root.addChildren([
]), ]),
authenticatedRoute.addChildren([ authenticatedRoute.addChildren([
indexRoute, indexRoute,
nativeCompositionDiffRoute,
devRoute, devRoute,
newOrgPage, newOrgPage,
manageRoute, manageRoute,