浏览代码

feat: hide apps which are not supported on the host architecture

Nicolas Meienberger 2 年之前
父节点
当前提交
b9667f29f1

+ 4 - 0
packages/system-api/src/core/config/TipiConfig.ts

@@ -2,6 +2,7 @@ import { z } from 'zod';
 import * as dotenv from 'dotenv';
 import * as dotenv from 'dotenv';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
 import { readJsonFile } from '../../modules/fs/fs.helpers';
 import { readJsonFile } from '../../modules/fs/fs.helpers';
+import { AppSupportedArchitecturesEnum } from '../../modules/apps/apps.types';
 
 
 if (process.env.NODE_ENV !== 'production') {
 if (process.env.NODE_ENV !== 'production') {
   dotenv.config({ path: '.env.dev' });
   dotenv.config({ path: '.env.dev' });
@@ -21,11 +22,13 @@ const {
   APPS_REPO_URL = '',
   APPS_REPO_URL = '',
   DOMAIN = '',
   DOMAIN = '',
   STORAGE_PATH = '/runtipi',
   STORAGE_PATH = '/runtipi',
+  ARCHITECTURE = 'amd64',
 } = process.env;
 } = process.env;
 
 
 const configSchema = z.object({
 const configSchema = z.object({
   NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]),
   NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]),
   status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]),
   status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]),
+  architecture: z.nativeEnum(AppSupportedArchitecturesEnum),
   logs: z.object({
   logs: z.object({
     LOGS_FOLDER: z.string(),
     LOGS_FOLDER: z.string(),
     LOGS_APP: z.string(),
     LOGS_APP: z.string(),
@@ -56,6 +59,7 @@ class Config {
         LOGS_ERROR,
         LOGS_ERROR,
       },
       },
       NODE_ENV: NODE_ENV as z.infer<typeof configSchema>['NODE_ENV'],
       NODE_ENV: NODE_ENV as z.infer<typeof configSchema>['NODE_ENV'],
+      architecture: ARCHITECTURE as z.infer<typeof configSchema>['architecture'],
       rootFolder: '/runtipi',
       rootFolder: '/runtipi',
       internalIp: INTERNAL_IP,
       internalIp: INTERNAL_IP,
       version: TIPI_VERSION,
       version: TIPI_VERSION,

+ 5 - 2
packages/system-api/src/modules/apps/__tests__/apps.factory.ts

@@ -1,5 +1,5 @@
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
-import { AppCategoriesEnum, AppInfo, AppStatusEnum, FieldTypes } from '../apps.types';
+import { AppCategoriesEnum, AppInfo, AppStatusEnum, AppSupportedArchitecturesEnum, FieldTypes } from '../apps.types';
 import App from '../app.entity';
 import App from '../app.entity';
 
 
 interface IProps {
 interface IProps {
@@ -10,10 +10,11 @@ interface IProps {
   exposed?: boolean;
   exposed?: boolean;
   domain?: string;
   domain?: string;
   exposable?: boolean;
   exposable?: boolean;
+  supportedArchitectures?: AppSupportedArchitecturesEnum[];
 }
 }
 
 
 const createApp = async (props: IProps) => {
 const createApp = async (props: IProps) => {
-  const { installed = false, status = AppStatusEnum.RUNNING, requiredPort, randomField = false, exposed = false, domain = '', exposable = false } = props;
+  const { installed = false, status = AppStatusEnum.RUNNING, requiredPort, randomField = false, exposed = false, domain = '', exposable = false, supportedArchitectures } = props;
 
 
   const categories = Object.values(AppCategoriesEnum);
   const categories = Object.values(AppCategoriesEnum);
 
 
@@ -29,6 +30,7 @@ const createApp = async (props: IProps) => {
         env_variable: 'TEST_FIELD',
         env_variable: 'TEST_FIELD',
       },
       },
     ],
     ],
+
     name: faker.random.word(),
     name: faker.random.word(),
     description: faker.random.words(),
     description: faker.random.words(),
     tipi_version: faker.datatype.number({ min: 1, max: 10 }),
     tipi_version: faker.datatype.number({ min: 1, max: 10 }),
@@ -37,6 +39,7 @@ const createApp = async (props: IProps) => {
     source: faker.internet.url(),
     source: faker.internet.url(),
     categories: [categories[faker.datatype.number({ min: 0, max: categories.length - 1 })]],
     categories: [categories[faker.datatype.number({ min: 0, max: categories.length - 1 })]],
     exposable,
     exposable,
+    supported_architectures: supportedArchitectures,
   };
   };
 
 
   if (randomField) {
   if (randomField) {

+ 78 - 1
packages/system-api/src/modules/apps/__tests__/apps.service.test.ts

@@ -1,12 +1,13 @@
 import AppsService from '../apps.service';
 import AppsService from '../apps.service';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
-import { AppInfo, AppStatusEnum } from '../apps.types';
+import { AppInfo, AppStatusEnum, AppSupportedArchitecturesEnum } from '../apps.types';
 import App from '../app.entity';
 import App from '../app.entity';
 import { createApp } from './apps.factory';
 import { createApp } from './apps.factory';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import { DataSource } from 'typeorm';
 import { DataSource } from 'typeorm';
 import { getEnvMap } from '../apps.helpers';
 import { getEnvMap } from '../apps.helpers';
 import EventDispatcher, { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher';
 import EventDispatcher, { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher';
+import { setConfig } from '../../../core/config/TipiConfig';
 
 
 jest.mock('fs-extra');
 jest.mock('fs-extra');
 jest.mock('child_process');
 jest.mock('child_process');
@@ -152,6 +153,38 @@ describe('Install app', () => {
 
 
     await expect(AppsService.installApp(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`Domain test.com already in use by app ${app2.appInfo.id}`);
     await expect(AppsService.installApp(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`Domain test.com already in use by app ${app2.appInfo.id}`);
   });
   });
+
+  it('Should throw if architecure is not supported', async () => {
+    const { MockFiles, appInfo } = await createApp({ supportedArchitectures: [AppSupportedArchitecturesEnum.ARM] });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} is not supported on this architecture`);
+  });
+
+  it('Can install if architecture is supported', async () => {
+    setConfig('architecture', AppSupportedArchitecturesEnum.ARM);
+    const { MockFiles, appInfo } = await createApp({ supportedArchitectures: [AppSupportedArchitecturesEnum.ARM, AppSupportedArchitecturesEnum.ARM64] });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    await AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' });
+    const app = await App.findOne({ where: { id: appInfo.id } });
+
+    expect(app).toBeDefined();
+  });
+
+  it('Can install if no architecture is specified', async () => {
+    setConfig('architecture', AppSupportedArchitecturesEnum.ARM);
+    const { MockFiles, appInfo } = await createApp({ supportedArchitectures: undefined });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    await AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' });
+    const app = await App.findOne({ where: { id: appInfo.id } });
+
+    expect(app).toBeDefined();
+  });
 });
 });
 
 
 describe('Uninstall app', () => {
 describe('Uninstall app', () => {
@@ -431,6 +464,50 @@ describe('List apps', () => {
     expect(apps[1].id).toBe(sortedApps[1].id);
     expect(apps[1].id).toBe(sortedApps[1].id);
     expect(apps[0].description).toBe('md desc');
     expect(apps[0].description).toBe('md desc');
   });
   });
+
+  it('Should not list apps that have supportedArchitectures and are not supported', async () => {
+    // Arrange
+    setConfig('architecture', AppSupportedArchitecturesEnum.ARM64);
+    const app3 = await createApp({ supportedArchitectures: [AppSupportedArchitecturesEnum.ARM] });
+    // @ts-ignore
+    fs.__createMockFiles(Object.assign(app3.MockFiles));
+
+    // Act
+    const { apps } = await AppsService.listApps();
+
+    // Assert
+    expect(apps).toBeDefined();
+    expect(apps.length).toBe(0);
+  });
+
+  it('Should list apps that have supportedArchitectures and are supported', async () => {
+    // Arrange
+    setConfig('architecture', AppSupportedArchitecturesEnum.ARM);
+    const app3 = await createApp({ supportedArchitectures: [AppSupportedArchitecturesEnum.ARM] });
+    // @ts-ignore
+    fs.__createMockFiles(Object.assign(app3.MockFiles));
+    // Act
+    const { apps } = await AppsService.listApps();
+
+    // Assert
+    expect(apps).toBeDefined();
+    expect(apps.length).toBe(1);
+  });
+
+  it('Should list apps that have no supportedArchitectures specified', async () => {
+    // Arrange
+    setConfig('architecture', AppSupportedArchitecturesEnum.ARM);
+    const app3 = await createApp({});
+    // @ts-ignore
+    fs.__createMockFiles(Object.assign(app3.MockFiles));
+
+    // Act
+    const { apps } = await AppsService.listApps();
+
+    // Assert
+    expect(apps).toBeDefined();
+    expect(apps.length).toBe(1);
+  });
 });
 });
 
 
 describe('Start all apps', () => {
 describe('Start all apps', () => {

+ 4 - 0
packages/system-api/src/modules/apps/apps.helpers.ts

@@ -26,6 +26,10 @@ export const checkAppRequirements = async (appName: string) => {
     }
     }
   }
   }
 
 
+  if (configFile?.supported_architectures && !configFile.supported_architectures.includes(getConfig().architecture)) {
+    throw new Error(`App ${appName} is not supported on this architecture`);
+  }
+
   return valid;
   return valid;
 };
 };
 
 

+ 13 - 1
packages/system-api/src/modules/apps/apps.service.ts

@@ -9,6 +9,18 @@ import { getConfig } from '../../core/config/TipiConfig';
 import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher';
 import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher';
 
 
 const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
 const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
+const filterApp = (app: AppInfo): boolean => {
+  if (!app.supported_architectures) {
+    return true;
+  }
+
+  const arch = getConfig().architecture;
+  return app.supported_architectures.includes(arch);
+};
+
+const filterApps = (apps: AppInfo[]): AppInfo[] => {
+  return apps.sort(sortApps).filter(filterApp);
+};
 
 
 /**
 /**
  * Start all apps which had the status RUNNING in the database
  * Start all apps which had the status RUNNING in the database
@@ -159,7 +171,7 @@ const listApps = async (): Promise<ListAppsResonse> => {
     app.description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app.id}/metadata/description.md`);
     app.description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app.id}/metadata/description.md`);
   });
   });
 
 
-  return { apps: apps.sort(sortApps), total: apps.length };
+  return { apps: filterApps(apps), total: apps.length };
 };
 };
 
 
 /**
 /**

+ 13 - 0
packages/system-api/src/modules/apps/apps.types.ts

@@ -41,6 +41,12 @@ export enum AppStatusEnum {
   UPDATING = 'updating',
   UPDATING = 'updating',
 }
 }
 
 
+export enum AppSupportedArchitecturesEnum {
+  ARM = 'arm',
+  ARM64 = 'arm64',
+  AMD64 = 'amd64',
+}
+
 registerEnumType(AppCategoriesEnum, {
 registerEnumType(AppCategoriesEnum, {
   name: 'AppCategoriesEnum',
   name: 'AppCategoriesEnum',
 });
 });
@@ -49,6 +55,10 @@ registerEnumType(FieldTypes, {
   name: 'FieldTypesEnum',
   name: 'FieldTypesEnum',
 });
 });
 
 
+registerEnumType(AppSupportedArchitecturesEnum, {
+  name: 'AppSupportedArchitecturesEnum',
+});
+
 @ObjectType()
 @ObjectType()
 class FormField {
 class FormField {
   @Field(() => FieldTypes)
   @Field(() => FieldTypes)
@@ -128,6 +138,9 @@ class AppInfo {
 
 
   @Field(() => Boolean, { nullable: true })
   @Field(() => Boolean, { nullable: true })
   exposable?: boolean;
   exposable?: boolean;
+
+  @Field(() => [AppSupportedArchitecturesEnum], { nullable: true })
+  supported_architectures?: AppSupportedArchitecturesEnum[];
 }
 }
 
 
 @ObjectType()
 @ObjectType()

+ 10 - 0
scripts/start.sh

@@ -61,6 +61,16 @@ INTERNAL_IP="$(ip addr show "${NETWORK_INTERFACE}" | grep "inet " | awk '{print
 
 
 if [[ "$ARCHITECTURE" == "aarch64" ]]; then
 if [[ "$ARCHITECTURE" == "aarch64" ]]; then
   ARCHITECTURE="arm64"
   ARCHITECTURE="arm64"
+elif [[ "$ARCHITECTURE" == "armv7l" ]]; then
+  ARCHITECTURE="arm"
+elif [[ "$ARCHITECTURE" == "x86_64" ]]; then
+  ARCHITECTURE="amd64"
+fi
+
+# If none of the above conditions are met, the architecture is not supported
+if [[ "$ARCHITECTURE" != "arm64" ]] && [[ "$ARCHITECTURE" != "arm" ]] && [[ "$ARCHITECTURE" != "amd64" ]]; then
+  echo "Architecture not supported!"
+  exit 1
 fi
 fi
 
 
 ### --------------------------------
 ### --------------------------------