Merge remote-tracking branch 'origin/master' into ndudnik/filter-by-identity

This commit is contained in:
Nikita Nek Dudnik
2020-06-22 09:35:23 +02:00
145 changed files with 1799 additions and 795 deletions
+2
View File
@@ -21,6 +21,8 @@
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.9",
"@backstage/config": "^0.1.1-alpha.9",
"@backstage/config-loader": "^0.1.1-alpha.9",
"@types/express": "^4.17.6",
"body-parser": "^1.19.0",
"compression": "^1.7.4",
@@ -57,6 +57,8 @@ export class EnvironmentHandler implements AuthProviderRouteHandlers {
async logout(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
await provider.logout(req, res);
if (provider.logout) {
await provider.logout(req, res);
}
}
}
@@ -21,13 +21,14 @@ import {
RedirectInfo,
RefreshTokenResponse,
ProfileInfo,
ProviderStrategy,
} from '../providers/types';
export const makeProfileInfo = (
profile: passport.Profile,
params: any,
): ProfileInfo => {
const { provider, displayName: name } = profile;
const { displayName: name } = profile;
let email = '';
if (profile.emails) {
@@ -51,7 +52,6 @@ export const makeProfileInfo = (
}
return {
provider,
name,
email,
picture,
@@ -100,12 +100,12 @@ export const executeFrameHandlerStrategy = async (
};
export const executeRefreshTokenStrategy = async (
providerstrategy: passport.Strategy,
providerStrategy: passport.Strategy,
refreshToken: string,
scope: string,
): Promise<RefreshTokenResponse> => {
return new Promise((resolve, reject) => {
const anyStrategy = providerstrategy as any;
const anyStrategy = providerStrategy as any;
const OAuth2 = anyStrategy._oauth2.constructor;
const oauth2 = new OAuth2(
anyStrategy._oauth2._clientId,
@@ -149,12 +149,12 @@ export const executeRefreshTokenStrategy = async (
};
export const executeFetchUserProfileStrategy = async (
providerstrategy: passport.Strategy,
providerStrategy: passport.Strategy,
accessToken: string,
params: any,
): Promise<ProfileInfo> => {
return new Promise((resolve, reject) => {
const anyStrategy = providerstrategy as any;
const anyStrategy = (providerStrategy as unknown) as ProviderStrategy;
anyStrategy.userProfile(
accessToken,
(error: Error, passportProfile: passport.Profile) => {
@@ -44,7 +44,9 @@ export const createAuthProviderRouter = (
router.get('/start', provider.start.bind(provider));
router.get('/handler/frame', provider.frameHandler.bind(provider));
router.post('/handler/frame', provider.frameHandler.bind(provider));
router.post('/logout', provider.logout.bind(provider));
if (provider.logout) {
router.post('/logout', provider.logout.bind(provider));
}
if (provider.refresh) {
router.get('/refresh', provider.refresh.bind(provider));
}
+191 -30
View File
@@ -18,48 +18,163 @@ import express from 'express';
import { Logger } from 'winston';
export type OAuthProviderOptions = {
/**
* Client ID of the auth provider.
*/
clientID: string;
/**
* Client Secret of the auth provider.
*/
clientSecret: string;
/**
* Callback URL to be passed to the auth provider to redirect to after the user signs in.
*/
callbackURL: string;
};
export type SAMLProviderConfig = {
entryPoint: string;
issuer: string;
};
export type EnvironmentProviderConfig = {
[key: string]: OAuthProviderConfig | SAMLProviderConfig;
};
export type AuthProviderConfig = {
baseUrl: string;
};
export type OAuthProviderConfig = {
/**
* Cookies can be marked with a secure flag to send cookies only when the request
* is over an encrypted channel (HTTPS).
*
* For development environment we don't mark the cookie as secure since we serve
* localhost over HTTP.
*/
secure: boolean;
appOrigin: string; // http://localhost:3000
/**
* The protocol://domain[:port] where the app (frontend) is hosted. This is used to post messages back
* to the window that initiates an auth request.
*/
appOrigin: string;
/**
* Client ID of the auth provider.
*/
clientId: string;
/**
* Client Secret of the auth provider.
*/
clientSecret: string;
};
export type EnvironmentProviderConfig = {
/**
* key, values are environment names and OAuthProviderConfigs
*
* For e.g
* {
* development: DevelopmentOAuthProviderConfig
* production: ProductionOAuthProviderConfig
* }
*/
[key: string]: OAuthProviderConfig;
};
export type AuthProviderConfig = {
/**
* The protocol://domain[:port] where the app is hosted. This is used to construct the
* callbackURL to redirect to once the user signs in to the auth provider.
*/
baseUrl: string;
};
/**
* Any OAuth provider needs to implement this interface which has provider specific
* handlers for different methods to perform authentication, get access tokens,
* refresh tokens and perform sign out.
*/
export interface OAuthProviderHandlers {
/**
* This method initiates a sign in request with an auth provider.
* @param {express.Request} req
* @param options
*/
start(req: express.Request, options: any): Promise<any>;
/**
* Handles the redirect from the auth provider when the user has signed in.
* @param {express.Request} req
*/
handler(req: express.Request): Promise<any>;
/**
* (Optional) Given a refresh token and scope fetches a new access token from the auth provider.
* @param {string} refreshToken
* @param {string} scope
*/
refresh?(refreshToken: string, scope: string): Promise<any>;
/**
* (Optional) Sign out of the auth provider.
*/
logout?(): Promise<any>;
}
/**
* Any Auth provider needs to implement this interface which handles the routes in the
* auth backend. Any auth API requests from the frontend reaches these methods.
*
* The routes in the auth backend API are tied to these methods like below
*
* /auth/[provider]/start -> start
* /auth/[provider]/handler/frame -> frameHandler
* /auth/[provider]/refresh -> refresh
* /auth/[provider]/logout -> logout
*/
export interface AuthProviderRouteHandlers {
/**
* Handles the start route of the API. This initiates a sign in request with an auth provider.
*
* Request
* - scopes for the auth request (Optional)
* Response
* - redirect to the auth provider for the user to sign in or consent.
* - sets a nonce cookie and also pass the nonce as 'state' query parameter in the redirect request
*
* @param {express.Request} req
* @param {express.Response} res
*/
start(req: express.Request, res: express.Response): Promise<any>;
frameHandler(req: express.Request, res: express.Response): Promise<any>;
refresh?(req: express.Request, res: express.Response): Promise<any>;
logout(req: express.Request, res: express.Response): Promise<any>;
}
export type SAMLEnvironmentProviderConfig = {
[key: string]: SAMLProviderConfig;
};
/**
* Once the user signs in or consents in the OAuth screen, the auth provider redirects to the
* callbackURL which is handled by this method.
*
* Request
* - to contain a nonce cookie and a 'state' query parameter
* Response
* - postMessage to the window with a payload that contains accessToken, expiryInSeconds?, idToken? and scope.
* - sets a refresh token cookie if the auth provider supports refresh tokens
*
* @param {express.Request} req
* @param {express.Response} res
*/
frameHandler(req: express.Request, res: express.Response): Promise<any>;
/**
* (Optional) If the auth provider supports refresh tokens then this method handles
* requests to get a new access token.
*
* Request
* - to contain a refresh token cookie and scope (Optional) query parameter.
* Response
* - payload with accessToken, expiryInSeconds?, idToken?, scope and user profile information.
*
* @param {express.Request} req
* @param {express.Response} res
*/
refresh?(req: express.Request, res: express.Response): Promise<any>;
/**
* (Optional) Handles sign out requests
*
* Response
* - removes the refresh token cookie
*
* @param {express.Request} req
* @param {express.Response} res
*/
logout?(req: express.Request, res: express.Response): Promise<any>;
}
export type AuthProviderFactory = (
globalConfig: AuthProviderConfig,
@@ -68,27 +183,42 @@ export type AuthProviderFactory = (
) => AuthProviderRouteHandlers;
export type AuthInfoBase = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* (Optional) Id token issued for the signed in user.
*/
idToken?: string;
/**
* Expiry of the access token in seconds.
*/
expiresInSeconds?: number;
/**
* Scopes granted for the access token.
*/
scope: string;
};
export type AuthInfoWithProfile = AuthInfoBase & {
profile:
| {
provider: string;
email: string;
name?: string;
picture?: string;
}
| undefined;
/**
* Profile information of the signed in user.
*/
profile: ProfileInfo | undefined;
};
export type AuthInfoPrivate = {
/**
* A refresh token issued for the signed in user.
*/
refreshToken: string;
};
/**
* Payload sent as a post message after the auth request is complete.
* If successful then has a valid payload with Auth information else contains an error.
*/
export type AuthResponse =
| {
type: 'auth-result';
@@ -100,18 +230,49 @@ export type AuthResponse =
};
export type RedirectInfo = {
/**
* URL to redirect to
*/
url: string;
/**
* Status code to use for the redirect
*/
status?: number;
};
export type ProfileInfo = {
provider: string;
/**
* Email ID of the signed in user.
*/
email: string;
/**
* Display name that can be presented to the signed in user.
*/
name: string;
/**
* URL to an image that can be used as the display image or avatar of the
* signed in user.
*/
picture: string;
};
export type RefreshTokenResponse = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
params: any;
};
export type ProviderStrategy = {
userProfile(accessToken: string, callback: Function): void;
};
export type SAMLProviderConfig = {
entryPoint: string;
issuer: string;
};
export type SAMLEnvironmentProviderConfig = {
[key: string]: SAMLProviderConfig;
};
+4 -2
View File
@@ -20,9 +20,11 @@ import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import { Logger } from 'winston';
import { createAuthProviderRouter } from '../providers';
import { Config } from '@backstage/config';
export interface RouterOptions {
logger: Logger;
config: Config;
}
export async function createRouter(
@@ -38,7 +40,7 @@ export async function createRouter(
// TODO: read from app config
const config = {
backend: {
baseUrl: 'http://localhost:7000',
baseUrl: options.config.getString('backend.baseUrl'),
},
auth: {
providers: {
@@ -77,7 +79,7 @@ export async function createRouter(
const providerConfigs = config.auth.providers;
for (const [providerId, providerConfig] of Object.entries(providerConfigs)) {
const baseUrl = `${config.backend.baseUrl}/auth`;
const baseUrl = `${options.config.getString('backend.baseUrl')}/auth`;
logger.info(`Configuring provider, ${providerId}`);
try {
const providerRouter = createAuthProviderRouter(
@@ -19,6 +19,7 @@ import {
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
@@ -29,12 +30,13 @@ import { createRouter } from './router';
export interface ApplicationOptions {
enableCors: boolean;
logger: Logger;
config: Config;
}
export async function createStandaloneApplication(
options: ApplicationOptions,
): Promise<express.Application> {
const { enableCors, logger } = options;
const { enableCors, logger, config } = options;
const app = express();
app.use(helmet());
@@ -44,7 +46,7 @@ export async function createStandaloneApplication(
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/', await createRouter({ logger }));
app.use('/', await createRouter({ logger, config }));
app.use(notFoundHandler());
app.use(errorHandler());
@@ -17,6 +17,8 @@
import { Server } from 'http';
import { Logger } from 'winston';
import { createStandaloneApplication } from './standaloneApplication';
import { ConfigReader } from '@backstage/config';
import { loadConfig } from '@backstage/config-loader';
export interface ServerOptions {
port: number;
@@ -28,11 +30,13 @@ export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'auth-backend' });
const config = ConfigReader.fromConfigs(await loadConfig());
logger.debug('Creating application...');
const app = await createStandaloneApplication({
enableCors: options.enableCors,
logger,
config,
});
logger.debug('Starting application server...');
@@ -62,6 +62,11 @@ describe('DatabaseEntitiesCatalog', () => {
const result = await catalog.addOrUpdateEntity(entity);
expect(db.entities).toHaveBeenCalledTimes(1);
expect(db.entities).toHaveBeenCalledWith(expect.anything(), [
{ key: 'kind', values: ['b'] },
{ key: 'name', values: ['c'] },
{ key: 'namespace', values: ['d'] },
]);
expect(db.addEntity).toHaveBeenCalledTimes(1);
expect(result).toBe(entity);
});
@@ -71,20 +76,52 @@ describe('DatabaseEntitiesCatalog', () => {
apiVersion: 'a',
kind: 'b',
metadata: {
uid: 'uuuu',
uid: 'u',
name: 'c',
namespace: 'd',
},
};
db.entities.mockResolvedValue([]);
db.entityByUid.mockResolvedValue({
entity: {
apiVersion: 'a',
kind: 'b',
metadata: {
uid: 'u',
etag: 'e',
generation: 1,
name: 'c',
namespace: 'd',
},
},
});
db.updateEntity.mockResolvedValue({ entity });
const catalog = new DatabaseEntitiesCatalog(db);
const result = await catalog.addOrUpdateEntity(entity);
expect(db.entities).toHaveBeenCalledTimes(0);
expect(db.entityByUid).toHaveBeenCalledTimes(1);
expect(db.entityByUid).toHaveBeenCalledWith(expect.anything(), 'u');
expect(db.updateEntity).toHaveBeenCalledTimes(1);
expect(db.updateEntity).toHaveBeenCalledWith(
expect.anything(),
{
entity: {
apiVersion: 'a',
kind: 'b',
metadata: {
uid: 'u',
etag: 'e',
generation: 1,
name: 'c',
namespace: 'd',
},
},
},
'e',
1,
);
expect(result).toBe(entity);
});
@@ -101,19 +138,45 @@ describe('DatabaseEntitiesCatalog', () => {
apiVersion: 'a',
kind: 'b',
metadata: {
uid: 'u',
etag: 'e',
generation: 1,
name: 'c',
namespace: 'd',
},
};
db.entities.mockResolvedValue([{ entity: existing }]);
db.updateEntity.mockResolvedValue({ entity: added });
db.updateEntity.mockResolvedValue({ entity: existing });
const catalog = new DatabaseEntitiesCatalog(db);
const result = await catalog.addOrUpdateEntity(added);
expect(db.entities).toHaveBeenCalledTimes(1);
expect(db.entities).toHaveBeenCalledWith(expect.anything(), [
{ key: 'kind', values: ['b'] },
{ key: 'name', values: ['c'] },
{ key: 'namespace', values: ['d'] },
]);
expect(db.updateEntity).toHaveBeenCalledTimes(1);
expect(db.updateEntity).toHaveBeenCalledWith(
expect.anything(),
{
entity: {
apiVersion: 'a',
kind: 'b',
metadata: {
uid: 'u',
etag: 'e',
generation: 1,
name: 'c',
namespace: 'd',
},
},
},
'e',
1,
);
expect(result).toEqual(existing);
});
});
@@ -15,7 +15,10 @@
*/
import { NotFoundError } from '@backstage/backend-common';
import { LOCATION_ANNOTATION } from '@backstage/catalog-model';
import {
generateUpdatedEntity,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import type { Entity } from '@backstage/catalog-model';
import type { Database, DbEntityResponse, EntityFilters } from '../database';
import type { EntitiesCatalog } from './types';
@@ -43,9 +46,10 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
namespace: string | undefined,
name: string,
): Promise<Entity | undefined> {
return await this.database.transaction(tx =>
const response = await this.database.transaction(tx =>
this.entityByNameInternal(tx, kind, name, namespace),
);
return response?.entity;
}
async addOrUpdateEntity(
@@ -53,25 +57,30 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
locationId?: string,
): Promise<Entity> {
return await this.database.transaction(async tx => {
let response: DbEntityResponse;
// Find a matching (by uid, or by compound name, depending on the given
// entity) existing entity, to know whether to update or add
const existing = entity.metadata.uid
? await this.database.entityByUid(tx, entity.metadata.uid)
: await this.entityByNameInternal(
tx,
entity.kind,
entity.metadata.name,
entity.metadata.namespace,
);
if (entity.metadata.uid) {
response = await this.database.updateEntity(tx, { locationId, entity });
} else {
const existing = await this.entityByNameInternal(
// If it's an update, run the algorithm for annotation merging, updating
// etag/generation, etc.
let response: DbEntityResponse;
if (existing) {
const updated = generateUpdatedEntity(existing.entity, entity);
response = await this.database.updateEntity(
tx,
entity.kind,
entity.metadata.name,
entity.metadata.namespace,
{ locationId, entity: updated },
existing.entity.metadata.etag,
existing.entity.metadata.generation,
);
if (existing) {
response = await this.database.updateEntity(tx, {
locationId,
entity,
});
} else {
response = await this.database.addEntity(tx, { locationId, entity });
}
} else {
response = await this.database.addEntity(tx, { locationId, entity });
}
return response.entity;
@@ -110,7 +119,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
kind: string,
name: string,
namespace: string | undefined,
): Promise<Entity | undefined> {
): Promise<DbEntityResponse | undefined> {
const matches = await this.database.entities(tx, [
{ key: 'kind', values: [kind] },
{ key: 'name', values: [name] },
@@ -123,6 +132,6 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
},
]);
return matches.length ? matches[0].entity : undefined;
return matches.length ? matches[0] : undefined;
}
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ConflictError, NotFoundError } from '@backstage/backend-common';
import { ConflictError } from '@backstage/backend-common';
import type { Entity, Location } from '@backstage/catalog-model';
import { DatabaseManager } from './DatabaseManager';
import { Database, DatabaseLocationUpdateLogStatus } from './types';
@@ -199,9 +199,7 @@ describe('CommonDatabase', () => {
);
expect(updated.entity.apiVersion).toEqual(added.entity.apiVersion);
expect(updated.entity.kind).toEqual(added.entity.kind);
expect(updated.entity.metadata.etag).not.toEqual(
added.entity.metadata.etag,
);
expect(updated.entity.metadata.etag).toEqual(added.entity.metadata.etag);
expect(updated.entity.metadata.generation).toEqual(
added.entity.metadata.generation,
);
@@ -220,41 +218,21 @@ describe('CommonDatabase', () => {
expect(updated.entity.metadata.name).toEqual('new!');
});
it('can update fields if kind, name, and namespace match', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.apiVersion = 'something.new';
delete added.entity.metadata.uid;
delete added.entity.metadata.generation;
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.apiVersion).toEqual('something.new');
});
it('rejects if kind, name, but not namespace match', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.apiVersion = 'something.new';
delete added.entity.metadata.uid;
delete added.entity.metadata.generation;
added.entity.metadata.namespace = 'something.wrong';
await expect(
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(NotFoundError);
});
it('fails to update an entity if etag does not match', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.etag = 'garbage';
await expect(
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }, 'garbage'),
),
).rejects.toThrow(ConflictError);
});
it('fails to update an entity if generation does not match', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.generation! += 100;
await expect(
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }, undefined, 1e20),
),
).rejects.toThrow(ConflictError);
});
});
@@ -19,7 +19,14 @@ import {
InputError,
NotFoundError,
} from '@backstage/backend-common';
import { Entity, EntityMeta, Location } from '@backstage/catalog-model';
import {
Entity,
EntityMeta,
entityMetaGeneratedFields,
generateEntityEtag,
generateEntityUid,
Location,
} from '@backstage/catalog-model';
import Knex from 'knex';
import lodash from 'lodash';
import { v4 as uuidv4 } from 'uuid';
@@ -38,88 +45,9 @@ import type {
EntityFilters,
} from './types';
function getStrippedMetadata(metadata: EntityMeta): EntityMeta {
const output = lodash.cloneDeep(metadata);
delete output.uid;
delete output.etag;
delete output.generation;
return output;
}
function serializeMetadata(metadata: EntityMeta): string {
return JSON.stringify(getStrippedMetadata(metadata));
}
function serializeSpec(spec: Entity['spec']): DbEntitiesRow['spec'] {
if (!spec) {
return null;
}
return JSON.stringify(spec);
}
function toEntityRow(
locationId: string | undefined,
entity: Entity,
): DbEntitiesRow {
return {
id: entity.metadata.uid!,
location_id: locationId || null,
etag: entity.metadata.etag!,
generation: entity.metadata.generation!,
api_version: entity.apiVersion,
kind: entity.kind,
name: entity.metadata.name,
namespace: entity.metadata.namespace || null,
metadata: serializeMetadata(entity.metadata),
spec: serializeSpec(entity.spec),
};
}
function toEntityResponse(row: DbEntitiesRow): DbEntityResponse {
const entity: Entity = {
apiVersion: row.api_version,
kind: row.kind,
metadata: {
...(JSON.parse(row.metadata) as Entity['metadata']),
uid: row.id,
etag: row.etag,
generation: Number(row.generation), // cast because of sqlite
},
};
if (row.spec) {
const spec = JSON.parse(row.spec);
entity.spec = spec;
}
return {
locationId: row.location_id || undefined,
entity,
};
}
function specsAreEqual(
first: string | null,
second: object | undefined,
): boolean {
if (!first && !second) {
return true;
} else if (!first || !second) {
return false;
}
return lodash.isEqual(JSON.parse(first), second);
}
function generateUid(): string {
return uuidv4();
}
function generateEtag(): string {
return Buffer.from(uuidv4(), 'utf8').toString('base64').replace(/[^\w]/g, '');
}
/**
* The core database implementation.
*/
export class CommonDatabase implements Database {
constructor(
private readonly database: Knex,
@@ -163,12 +91,12 @@ export class CommonDatabase implements Database {
const newEntity = lodash.cloneDeep(request.entity);
newEntity.metadata = {
...newEntity.metadata,
uid: generateUid(),
etag: generateEtag(),
uid: generateEntityUid(),
etag: generateEntityEtag(),
generation: 1,
};
const newRow = toEntityRow(request.locationId, newEntity);
const newRow = this.toEntityRow(request.locationId, newEntity);
await tx<DbEntitiesRow>('entities').insert(newRow);
await this.updateEntitiesSearch(tx, newRow.id, newEntity);
@@ -178,35 +106,20 @@ export class CommonDatabase implements Database {
async updateEntity(
txOpaque: unknown,
request: DbEntityRequest,
matchingEtag?: string,
matchingGeneration?: number,
): Promise<DbEntityResponse> {
const tx = txOpaque as Knex.Transaction<any, any>;
const { kind } = request.entity;
const {
uid,
etag: expectedOldEtag,
generation: expectedOldGeneration,
name,
namespace,
} = request.entity.metadata ?? {};
const { uid } = request.entity.metadata;
// Find existing entities that match the given metadata
let entitySelector: Partial<DbEntitiesRow>;
if (uid) {
entitySelector = { id: uid };
} else if (kind && name) {
entitySelector = {
kind,
name: name,
namespace: namespace || null,
};
} else {
throw new InputError(
'Must specify either uid, or kind + name + namespace to be able to identify an entity',
);
if (uid === undefined) {
throw new InputError('Must specify uid when updating entities');
}
// Find existing entity
const oldRows = await tx<DbEntitiesRow>('entities')
.where(entitySelector)
.where({ id: uid })
.select();
if (oldRows.length !== 1) {
throw new NotFoundError('No matching entity found');
@@ -217,51 +130,26 @@ export class CommonDatabase implements Database {
// The Number cast is here because sqlite reads it as a string, no matter
// what the table actually says
oldRow.generation = Number(oldRow.generation);
if (expectedOldEtag) {
if (expectedOldEtag !== oldRow.etag) {
if (matchingEtag) {
if (matchingEtag !== oldRow.etag) {
throw new ConflictError(
`Etag mismatch, expected="${expectedOldEtag}" found="${oldRow.etag}"`,
`Etag mismatch, expected="${matchingEtag}" found="${oldRow.etag}"`,
);
}
}
if (expectedOldGeneration) {
if (expectedOldGeneration !== oldRow.generation) {
if (matchingGeneration) {
if (matchingGeneration !== oldRow.generation) {
throw new ConflictError(
`Generation mismatch, expected="${expectedOldGeneration}" found="${oldRow.generation}"`,
`Generation mismatch, expected="${matchingGeneration}" found="${oldRow.generation}"`,
);
}
}
// Build the new shape of the entity
const newEtag = generateEtag();
const newGeneration = specsAreEqual(oldRow.spec, request.entity.spec)
? oldRow.generation
: oldRow.generation + 1;
const newEntity = lodash.cloneDeep(request.entity);
newEntity.metadata = {
...newEntity.metadata,
uid: oldRow.id,
etag: newEtag,
generation: newGeneration,
};
// Preserve annotations that were set on the old version of the entity,
// unless the new version overwrites them
if (oldRow.metadata) {
const oldMetadata = JSON.parse(oldRow.metadata) as EntityMeta;
if (oldMetadata.annotations) {
newEntity.metadata.annotations = {
...oldMetadata.annotations,
...newEntity.metadata.annotations,
};
}
}
await this.ensureNoSimilarNames(tx, newEntity);
await this.ensureNoSimilarNames(tx, request.entity);
// Store the updated entity; select on the old etag to ensure that we do
// not lose to another writer
const newRow = toEntityRow(request.locationId, newEntity);
const newRow = this.toEntityRow(request.locationId, request.entity);
const updatedRows = await tx<DbEntitiesRow>('entities')
.where({ id: oldRow.id, etag: oldRow.etag })
.update(newRow);
@@ -271,8 +159,9 @@ export class CommonDatabase implements Database {
throw new ConflictError(`Failed to update entity`);
}
await this.updateEntitiesSearch(tx, oldRow.id, newEntity);
return { locationId: request.locationId, entity: newEntity };
await this.updateEntitiesSearch(tx, oldRow.id, request.entity);
return request;
}
async entities(
@@ -328,7 +217,7 @@ export class CommonDatabase implements Database {
.orderBy('name', 'asc')
.groupBy('id');
return rows.map(row => toEntityResponse(row));
return rows.map(row => this.toEntityResponse(row));
}
async entity(
@@ -347,7 +236,7 @@ export class CommonDatabase implements Database {
return undefined;
}
return toEntityResponse(rows[0]);
return this.toEntityResponse(rows[0]);
}
async entityByUid(
@@ -362,7 +251,7 @@ export class CommonDatabase implements Database {
return undefined;
}
return toEntityResponse(rows[0]);
return this.toEntityResponse(rows[0]);
}
async removeEntity(txOpaque: unknown, uid: string): Promise<void> {
@@ -524,4 +413,47 @@ export class CommonDatabase implements Database {
}
}
}
private toEntityRow(
locationId: string | undefined,
entity: Entity,
): DbEntitiesRow {
return {
id: entity.metadata.uid!,
location_id: locationId || null,
etag: entity.metadata.etag!,
generation: entity.metadata.generation!,
api_version: entity.apiVersion,
kind: entity.kind,
name: entity.metadata.name,
namespace: entity.metadata.namespace || null,
metadata: JSON.stringify(
lodash.omit(entity.metadata, ...entityMetaGeneratedFields),
),
spec: entity.spec ? JSON.stringify(entity.spec) : null,
};
}
private toEntityResponse(row: DbEntitiesRow): DbEntityResponse {
const entity: Entity = {
apiVersion: row.api_version,
kind: row.kind,
metadata: {
...(JSON.parse(row.metadata) as EntityMeta),
uid: row.id,
etag: row.etag,
generation: Number(row.generation), // cast because of sqlite
},
};
if (row.spec) {
const spec = JSON.parse(row.spec);
entity.spec = spec;
}
return {
locationId: row.location_id || undefined,
entity,
};
}
}
+12 -6
View File
@@ -104,21 +104,27 @@ export type Database = {
/**
* Updates an existing entity in the catalog.
*
* The given entity must contain enough information to identify an already
* stored entity in the catalog - either by uid, or by kind + namespace +
* name. If no matching entity is found, the operation fails.
* The given entity must contain an uid to identify an already stored entity
* in the catalog. If it is missing or if no matching entity is found, the
* operation fails.
*
* If etag or generation are given, they are taken into account. Attempts to
* update a matching entity, but where the etag and/or generation are not
* equal to the passed values, will fail.
* If matchingEtag or matchingGeneration are given, they are taken into
* account. Attempts to update a matching entity, but where the etag and/or
* generation are not equal to the passed values, will fail.
*
* @param tx An ongoing transaction
* @param request The entity being updated
* @param matchingEtag If specified, reject with ConflictError if not
* matching the entry in the database
* @param matchingGeneration If specified, reject with ConflictError if not
* matching the entry in the database
* @returns The updated entity
*/
updateEntity(
tx: unknown,
request: DbEntityRequest,
matchingEtag?: string,
matchingGeneration?: number,
): Promise<DbEntityResponse>;
entities(tx: unknown, filters?: EntityFilters): Promise<DbEntityResponse[]>;
@@ -15,8 +15,12 @@
*/
import { InputError } from '@backstage/backend-common';
import { Entity, Location, LocationSpec } from '@backstage/catalog-model';
import lodash from 'lodash';
import {
Entity,
entityHasChanges,
Location,
LocationSpec,
} from '@backstage/catalog-model';
import { v4 as uuidv4 } from 'uuid';
import { Logger } from 'winston';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
@@ -173,7 +177,7 @@ export class HigherOrderOperations implements HigherOrderOperation {
if (!previous) {
this.logger.debug(`No such entity found, adding`);
await this.entitiesCatalog.addOrUpdateEntity(entity, location.id);
} else if (!this.entitiesAreEqual(previous, entity)) {
} else if (entityHasChanges(previous, entity)) {
this.logger.debug(`Different from existing entity, updating`);
await this.entitiesCatalog.addOrUpdateEntity(entity, location.id);
} else {
@@ -199,60 +203,4 @@ export class HigherOrderOperations implements HigherOrderOperation {
}
}
}
// Compares entities, ignoring generated and irrelevant data
private entitiesAreEqual(previous: Entity, next: Entity): boolean {
if (
previous.apiVersion !== next.apiVersion ||
previous.kind !== next.kind ||
!lodash.isEqual(previous.spec, next.spec) // Accept that {} !== undefined
) {
return false;
}
// Since the next annotations get merged into the previous, extract only
// the overlapping keys and check if their values match.
if (next.metadata.annotations) {
if (!previous.metadata.annotations) {
return false;
}
if (
!lodash.isEqual(
next.metadata.annotations,
lodash.pick(
previous.metadata.annotations,
Object.keys(next.metadata.annotations),
),
)
) {
return false;
}
}
const e1 = lodash.cloneDeep(previous);
const e2 = lodash.cloneDeep(next);
if (!e1.metadata.labels) {
e1.metadata.labels = {};
}
if (!e2.metadata.labels) {
e2.metadata.labels = {};
}
// Remove generated fields
delete e1.metadata.uid;
delete e1.metadata.etag;
delete e1.metadata.generation;
delete e2.metadata.uid;
delete e2.metadata.etag;
delete e2.metadata.generation;
// Remove already compared things
delete e1.metadata.annotations;
delete e1.spec;
delete e2.metadata.annotations;
delete e2.spec;
return lodash.isEqual(e1, e2);
}
}
+1 -2
View File
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-catalog",
"version": "0.1.1-alpha.9",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
@@ -15,23 +15,25 @@
*/
import React, { FC } from 'react';
import { Header, HomepageTimer, Page, pageTheme } from '@backstage/core';
import {
Header,
HomepageTimer,
Page,
pageTheme,
identityApiRef,
useApi,
} from '@backstage/core';
import { getTimeBasedGreeting } from './utils/timeUtil';
const CatalogLayout: FC<{}> = props => {
const { children } = props;
// const profile = useProfile();
const profile = { givenName: 'friend' };
const greeting = getTimeBasedGreeting();
const identityApi = useApi(identityApiRef);
return (
<Page theme={pageTheme.home}>
<Header
title={
profile
? `${greeting.greeting}, ${profile.givenName}!`
: greeting.greeting
}
title={`${greeting.greeting}, ${identityApi.getUserId()}!`}
subtitle="Backstage Service Catalog"
tooltip={greeting.language}
pageTitleOverride="Home"
@@ -24,7 +24,13 @@ import {
} from '@backstage/core';
import CatalogLayout from './CatalogLayout';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
import { Button, Link, makeStyles, Typography } from '@material-ui/core';
import {
Button,
Link,
makeStyles,
Typography,
withStyles,
} from '@material-ui/core';
import Edit from '@material-ui/icons/Edit';
import GitHub from '@material-ui/icons/GitHub';
import Star from '@material-ui/icons/Star';
@@ -71,6 +77,12 @@ export const CatalogPage: FC<{}> = () => {
const styles = useStyles();
const YellowStar = withStyles({
root: {
color: '#f3ba37',
},
})(Star);
const actions = [
(rowData: Entity) => {
const location = findLocationForEntityMeta(rowData.metadata);
@@ -110,7 +122,7 @@ export const CatalogPage: FC<{}> = () => {
(rowData: Entity) => {
const isStarred = isStarredEntity(rowData);
return {
icon: isStarred ? Star : StarOutline,
icon: isStarred ? YellowStar : StarOutline,
tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites',
onClick: () => toggleStarredEntity(rowData),
};
@@ -23,7 +23,7 @@ type Props = {
};
export const EntityMetadataCard: FC<Props> = ({ entity }) => (
<InfoCard title="Metadata">
<InfoCard title="Information">
<StructuredMetadataTable metadata={entity.metadata} />
</InfoCard>
);
@@ -149,11 +149,11 @@ export const EntityPage: FC<{}> = () => {
<HeaderTabs tabs={tabs} />
<Content>
<Grid container spacing={3} direction="column">
<Grid item>
<Grid container spacing={3}>
<Grid item sm={4}>
<EntityMetadataCard entity={entity} />
</Grid>
<Grid item>
<Grid item sm={8}>
<SentryIssuesWidget
sentryProjectId="sample-sentry-project-id"
statsFor="24h"
+1 -2
View File
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-circleci",
"version": "0.1.1-alpha.9",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
+2 -5
View File
@@ -25,11 +25,8 @@ export const App = () => {
<AppStateProvider>
<>
<Routes>
<Route path="/circleci" element={<BuildsPage />} />
<Route
path="/circleci/build/:buildId"
element={<DetailedViewPage />}
/>
<Route path="*" element={<BuildsPage />} />
<Route path="/build/:buildId" element={<DetailedViewPage />} />
</Routes>
<Settings />
</>
@@ -44,13 +44,13 @@ const Settings = () => {
useEffect(() => {
if (tokenFromStore !== token) {
setToken(tokenFromStore);
setToken(token);
}
if (ownerFromStore !== owner) {
setOwner(ownerFromStore);
setOwner(owner);
}
if (repoFromStore !== repo) {
setRepo(repoFromStore);
setRepo(repo);
}
}, [ownerFromStore, repoFromStore, tokenFromStore, token, owner, repo]);
+1 -2
View File
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-explore",
"version": "0.1.1-alpha.9",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
+1 -2
View File
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-gitops-profiles",
"version": "0.1.1-alpha.9",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
+1 -2
View File
@@ -18,8 +18,7 @@
"backstage"
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli plugin:build",
+1 -2
View File
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-lighthouse",
"version": "0.1.1-alpha.9",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
+1 -2
View File
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-register-component",
"version": "0.1.1-alpha.9",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
@@ -14,13 +14,12 @@
* limitations under the License.
*/
import React, { ComponentProps } from 'react';
import { render, cleanup } from '@testing-library/react';
import { RegisterComponentResultDialog } from './RegisterComponentResultDialog';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { cleanup, render } from '@testing-library/react';
import React, { ComponentProps } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Entity } from '@backstage/catalog-model';
import { RegisterComponentResultDialog } from './RegisterComponentResultDialog';
const setup = (
props?: Partial<ComponentProps<typeof RegisterComponentResultDialog>>,
@@ -52,6 +51,7 @@ it('should show a list of components if success', async () => {
const { rendered } = setup({
entities: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Component1',
@@ -61,6 +61,7 @@ it('should show a list of components if success', async () => {
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Component2',
@@ -69,7 +70,7 @@ it('should show a list of components if success', async () => {
type: 'service',
},
},
] as Entity[],
],
});
expect(
+1 -2
View File
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-scaffolder",
"version": "0.1.1-alpha.9",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
@@ -20,23 +20,33 @@ import {
Content,
ContentHeader,
Header,
SupportButton,
Page,
pageTheme,
} from '@backstage/core';
import { Typography, Link, Button } from '@material-ui/core';
import { Button, Grid, Link, Typography } from '@material-ui/core';
import { Link as RouterLink } from 'react-router-dom';
import TemplateCard from '../TemplateCard';
// TODO(blam): Connect to backend
const STATIC_DATA = [
{
id: 'springboot-template',
type: 'service',
name: 'Spring Boot Service',
tags: ['Recommended', 'Java'],
description:
'Standard Spring Boot (Java) microservice with recommended configuration.',
ownerId: 'spotify',
},
{
id: 'react-ssr-template',
type: 'web-infra',
type: 'website',
name: 'SSR React Website',
tags: ['Experimental'],
tags: ['Recommended', 'React'],
description:
'Next.js application skeleton for creating isomorphic web applications.',
ownerId: 'something',
ownerId: 'spotify',
},
];
const ScaffolderPage: React.FC<{}> = () => {
@@ -46,7 +56,7 @@ const ScaffolderPage: React.FC<{}> = () => {
pageTitleOverride="Create a new component"
title={
<>
Create a new component <Lifecycle alpha shorthand />{' '}
Create a new component <Lifecycle alpha shorthand />
</>
}
subtitle="Create new software components using standard templates"
@@ -61,6 +71,11 @@ const ScaffolderPage: React.FC<{}> = () => {
>
Register existing component
</Button>
<SupportButton>
Create new software components using standard templates. Different
templates create different kinds of components (services, websites,
documentation, ...).
</SupportButton>
</ContentHeader>
<Typography variant="body2" paragraph style={{ fontStyle: 'italic' }}>
<strong>NOTE!</strong> This feature is WIP. You can follow progress{' '}
@@ -69,7 +84,7 @@ const ScaffolderPage: React.FC<{}> = () => {
</Link>
.
</Typography>
<div style={{ display: 'flex' }}>
<Grid container>
{STATIC_DATA.map(item => {
return (
<TemplateCard
@@ -81,7 +96,7 @@ const ScaffolderPage: React.FC<{}> = () => {
/>
);
})}
</div>
</Grid>
</Content>
</Page>
);
+1 -2
View File
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-sentry",
"version": "0.1.1-alpha.9",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
+1 -2
View File
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-tech-radar",
"version": "0.1.1-alpha.9",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
+1 -2
View File
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-welcome",
"version": "0.1.1-alpha.9",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"private": false,
"license": "Apache-2.0",
@@ -39,7 +39,8 @@ import {
} from '@backstage/core';
const WelcomePage: FC<{}> = () => {
const appTitle = useApi(configApiRef).getString('app.title') ?? 'Backstage';
const appTitle =
useApi(configApiRef).getOptionalString('app.title') ?? 'Backstage';
const profile = { givenName: '' };
return (