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',
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',

View file

@ -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<string>;
};
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 {

View file

@ -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 [];
}

View file

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

View file

@ -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

View file

@ -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 | {
schemaVersion: SchemaVersion;
target: Target;
nativeCompositionResult: {
supergraphSdl: string | null;
errors: Array<{ message: string }> | 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<null | {

View file

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

View file

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

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 { 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 <NativeCompositionDiff projectId={projectId} />;
},
});
const newOrgPage = createRoute({
getParentRoute: () => authenticatedRoute,
path: 'org/new',
@ -836,6 +846,7 @@ const routeTree = root.addChildren([
]),
authenticatedRoute.addChildren([
indexRoute,
nativeCompositionDiffRoute,
devRoute,
newOrgPage,
manageRoute,