diff --git a/.eslintignore b/.eslintignore index fcca9768..10204d25 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ .eslintrc.js next.config.js jest.config.js +packages/ diff --git a/.gitignore b/.gitignore index 1737a1ae..31f15cc9 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ media /test-results/ /playwright-report/ /playwright/.cache/ +temp + +./traefik/ diff --git a/README.md b/README.md index 26211058..d888f778 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,6 @@ We are looking for contributions of all kinds. If you know design, development, Tipi is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. -Some of the bash scripts located in the `scripts` folder contain some snippets from [Umbrel](https://github.com/getumbrel/umbrel)'s code. Therefore some parts of the code are licensed under the PolyForm Noncommercial License 1.0.0 license. These parts have been marked with a comment above to clearly identify it. If you were to use this code in your own project, you have to keep the copyright notice and follow the license guidelines. We are actively working on re-writing those parts in order to make them available under the GPL license like the rest of our code. - ## 🗣 Community - [Matrix](https://matrix.to/#/#runtipi:matrix.org)
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b9e2c2ac..19e9a78a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -64,23 +64,10 @@ services: condition: service_healthy tipi-redis: condition: service_healthy + env_file: + - .env environment: NODE_ENV: development - INTERNAL_IP: ${INTERNAL_IP} - TIPI_VERSION: ${TIPI_VERSION} - JWT_SECRET: ${JWT_SECRET} - NGINX_PORT: ${NGINX_PORT} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_USERNAME: ${POSTGRES_USERNAME} - POSTGRES_DBNAME: ${POSTGRES_DBNAME} - POSTGRES_HOST: ${POSTGRES_HOST} - APPS_REPO_ID: ${APPS_REPO_ID} - APPS_REPO_URL: ${APPS_REPO_URL} - DOMAIN: ${DOMAIN} - ARCHITECTURE: ${ARCHITECTURE} - REDIS_HOST: ${REDIS_HOST} - DEMO_MODE: ${DEMO_MODE} - LOCAL_DOMAIN: ${LOCAL_DOMAIN} networks: - tipi_main_network ports: @@ -90,6 +77,7 @@ services: # - /dashboard/.next - ${PWD}/.env:/runtipi/.env - ${PWD}/src:/app/src + - ${PWD}/packages:/app/packages - ${PWD}/state:/runtipi/state - ${PWD}/repos:/runtipi/repos:ro - ${PWD}/apps:/runtipi/apps diff --git a/package.json b/package.json index bf901214..2f269bda 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "runtipi", - "version": "1.5.2", + "version": "1.6.0", "description": "A homeserver for everyone", "scripts": { + "prepare": "mkdir -p state && echo \"{}\" > state/system-info.json && echo \"random-seed\" > state/seed", "copy:migrations": "mkdir -p dist/migrations && cp -r ./src/server/migrations dist", "test": "dotenv -e .env.test -- jest --colors", "test:e2e": "NODE_ENV=test dotenv -e .env -e .env.e2e -- playwright test", @@ -11,6 +12,7 @@ "test:server": "jest --colors --selectProjects server --", "test:vite": "dotenv -e .env.test -- vitest run --coverage", "dev": "npm run copy:migrations && npm run db:migrate && nodemon", + "dev:watcher": "pnpm -r --filter cli dev", "db:migrate": "NODE_ENV=development dotenv -e .env -- tsx ./src/server/run-migrations-dev.ts", "start": "NODE_ENV=production node index.js", "lint": "next lint", @@ -19,8 +21,8 @@ "build:server": "node ./esbuild.js build", "build:next": "next build", "start:dev-container": "./.devcontainer/filewatcher.sh && npm run start:dev", - "start:rc": "docker-compose -f docker-compose.rc.yml --env-file .env up --build", - "start:dev": "./scripts/start-dev.sh", + "start:rc": "docker compose -f docker-compose.rc.yml --env-file .env up --build", + "start:dev": "npm run prepare && docker compose -f docker-compose.dev.yml up", "start:e2e": "./scripts/start-e2e.sh latest", "start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres:14", "version": "echo $npm_package_version", @@ -28,7 +30,8 @@ "test:build": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t meienberger/runtipi:test .", "test:build:arm64": "docker buildx build --platform linux/arm64 -t meienberger/runtipi:test .", "test:build:arm7": "docker buildx build --platform linux/arm/v7 -t meienberger/runtipi:test .", - "test:build:amd64": "docker buildx build --platform linux/amd64 -t meienberger/runtipi:test ." + "test:build:amd64": "docker buildx build --platform linux/amd64 -t meienberger/runtipi:test .", + "tsc": "tsc" }, "dependencies": { "@hookform/resolvers": "^3.1.1", @@ -41,6 +44,7 @@ "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@runtipi/postgres-migrations": "^5.3.0", + "@runtipi/shared": "workspace:^", "@tabler/core": "1.0.0-beta19", "@tabler/icons-react": "^2.23.0", "@tanstack/react-query": "^4.29.7", @@ -50,6 +54,7 @@ "@trpc/react-query": "^10.27.1", "@trpc/server": "^10.27.1", "argon2": "^0.30.3", + "bullmq": "^4.5.0", "clsx": "^1.1.1", "connect-redis": "^7.1.0", "cookies-next": "^2.1.2", @@ -118,6 +123,7 @@ "@typescript-eslint/parser": "^5.60.1", "@vitejs/plugin-react": "^4.0.1", "@vitest/coverage-v8": "^0.32.2", + "concurrently": "^8.2.0", "dotenv-cli": "^7.2.1", "drizzle-kit": "^0.19.2", "esbuild": "^0.16.17", diff --git a/packages/cli/.env.test b/packages/cli/.env.test new file mode 100644 index 00000000..29d4efda --- /dev/null +++ b/packages/cli/.env.test @@ -0,0 +1,8 @@ +INTERNAL_IP=localhost +ARCHITECTURE=arm64 +APPS_REPO_ID=repo-id +APPS_REPO_URL=https://test.com/test +ROOT_FOLDER_HOST=/runtipi +STORAGE_PATH=/runtipi +TIPI_VERSION=1 + diff --git a/docker-compose.yml b/packages/cli/assets/docker-compose.yml similarity index 83% rename from docker-compose.yml rename to packages/cli/assets/docker-compose.yml index 7aafb9e2..b24baf4c 100644 --- a/docker-compose.yml +++ b/packages/cli/assets/docker-compose.yml @@ -39,6 +39,8 @@ services: container_name: tipi-redis image: redis:alpine restart: on-failure + ports: + - 6379:6379 volumes: - ./data/redis:/data healthcheck: @@ -60,23 +62,10 @@ services: condition: service_healthy tipi-redis: condition: service_healthy + env_file: + - .env environment: - NODE_ENV: production - INTERNAL_IP: ${INTERNAL_IP} - TIPI_VERSION: ${TIPI_VERSION} - JWT_SECRET: ${JWT_SECRET} - NGINX_PORT: ${NGINX_PORT} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_USERNAME: ${POSTGRES_USERNAME} - POSTGRES_DBNAME: ${POSTGRES_DBNAME} - POSTGRES_HOST: ${POSTGRES_HOST} - APPS_REPO_ID: ${APPS_REPO_ID} - APPS_REPO_URL: ${APPS_REPO_URL} - DOMAIN: ${DOMAIN} - ARCHITECTURE: ${ARCHITECTURE} - REDIS_HOST: ${REDIS_HOST} - DEMO_MODE: ${DEMO_MODE} - LOCAL_DOMAIN: ${LOCAL_DOMAIN} + NODE_ENV: development volumes: - ${PWD}/.env:/runtipi/.env - ${PWD}/state:/runtipi/state @@ -116,7 +105,4 @@ services: networks: tipi_main_network: driver: bridge - ipam: - driver: default - config: - - subnet: 10.21.21.0/24 + name: runtipi_tipi_main_network diff --git a/traefik/dynamic/dynamic.yml b/packages/cli/assets/traefik/dynamic/dynamic.yml similarity index 100% rename from traefik/dynamic/dynamic.yml rename to packages/cli/assets/traefik/dynamic/dynamic.yml diff --git a/traefik/traefik.yml b/packages/cli/assets/traefik/traefik.yml similarity index 100% rename from traefik/traefik.yml rename to packages/cli/assets/traefik/traefik.yml diff --git a/packages/cli/src/executors/app/__tests__/app.executors.test.ts b/packages/cli/src/executors/app/__tests__/app.executors.test.ts new file mode 100644 index 00000000..93768115 --- /dev/null +++ b/packages/cli/src/executors/app/__tests__/app.executors.test.ts @@ -0,0 +1,161 @@ +import fs from 'fs'; +import { describe, it, expect, vi } from 'vitest'; +import path from 'path'; +import { faker } from '@faker-js/faker'; +import { AppExecutors } from '../app.executors'; +import { createAppConfig } from '@/tests/apps.factory'; +import * as dockerHelpers from '@/utils/docker-helpers'; +import { getEnv } from '@/utils/environment/environment'; +import { pathExists } from '@/utils/fs-helpers'; + +const { storagePath, rootFolderHost, appsRepoId } = getEnv(); + +describe('test: app executors', () => { + const appExecutors = new AppExecutors(); + + describe('test: installApp()', () => { + it('should run correct compose script', async () => { + // arrange + const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' })); + const config = createAppConfig({}, false); + + // act + const { message, success } = await appExecutors.installApp(config.id, config); + + // assert + const envExists = await pathExists(path.join(storagePath, 'app-data', config.id, 'app.env')); + + expect(success).toBe(true); + expect(message).toBe(`App ${config.id} installed successfully`); + expect(spy).toHaveBeenCalledWith(config.id, 'up -d'); + expect(envExists).toBe(true); + spy.mockRestore(); + }); + + it('should delete existing app folder', async () => { + // arrange + const config = createAppConfig(); + await fs.promises.mkdir(path.join(rootFolderHost, 'apps', config.id), { recursive: true }); + await fs.promises.writeFile(path.join(rootFolderHost, 'apps', config.id, 'test.txt'), 'test'); + + // act + await appExecutors.installApp(config.id, config); + + // assert + const exists = await pathExists(path.join(storagePath, 'apps', config.id, 'test.txt')); + + expect(exists).toBe(false); + }); + + it('should not delete existing app-data folder', async () => { + // arrange + const config = createAppConfig(); + const filename = faker.system.fileName(); + await fs.promises.writeFile(path.join(storagePath, 'app-data', config.id, filename), 'test'); + + // act + await appExecutors.installApp(config.id, config); + + // assert + const exists = await pathExists(path.join(storagePath, 'app-data', config.id, filename)); + + expect(exists).toBe(true); + }); + + it('should copy data folder from repo to app-data/id/data', async () => { + // arrange + const config = createAppConfig({}, false); + const filename = faker.system.fileName(); + await fs.promises.mkdir(path.join(rootFolderHost, 'repos', appsRepoId, 'apps', config.id, 'data'), { recursive: true }); + await fs.promises.writeFile(path.join(rootFolderHost, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'test'); + + // act + await appExecutors.installApp(config.id, config); + + // assert + const exists = await pathExists(path.join(storagePath, 'app-data', config.id, 'data', filename)); + const data = await fs.promises.readFile(path.join(storagePath, 'app-data', config.id, 'data', filename), 'utf-8'); + + expect(exists).toBe(true); + expect(data).toBe('test'); + }); + + it('should not overwrite exisiting app-data/id/data folder if repo has one', async () => { + // arrange + const config = createAppConfig(); + const filename = faker.system.fileName(); + await fs.promises.writeFile(path.join(storagePath, 'app-data', config.id, 'data', filename), 'test'); + await fs.promises.mkdir(path.join(rootFolderHost, 'repos', appsRepoId, 'apps', config.id, 'data'), { recursive: true }); + await fs.promises.writeFile(path.join(rootFolderHost, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'yeah'); + + // act + await appExecutors.installApp(config.id, config); + + // assert + const exists = await pathExists(path.join(storagePath, 'app-data', config.id, 'data', filename)); + const data = await fs.promises.readFile(path.join(storagePath, 'app-data', config.id, 'data', filename), 'utf-8'); + + expect(exists).toBe(true); + expect(data).toBe('test'); + }); + + it('should handle errors gracefully', async () => { + // arrange + const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.reject(new Error('test'))); + const config = createAppConfig(); + + // act + const { message, success } = await appExecutors.installApp(config.id, config); + + // assert + expect(success).toBe(false); + expect(message).toBe('test'); + spy.mockRestore(); + }); + + it('should error if app does not exist', async () => { + // act + const { message, success } = await appExecutors.installApp('inexistant', {}); + + // assert + expect(success).toBe(false); + expect(message).toBe(`App inexistant not found in repo ${appsRepoId}`); + }); + }); + + describe('test: stopApp()', () => { + it('should run correct compose script', async () => { + // arrange + const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' })); + const config = createAppConfig(); + + // act + const { message, success } = await appExecutors.stopApp(config.id, {}, true); + + // assert + expect(success).toBe(true); + expect(message).toBe(`App ${config.id} stopped successfully`); + expect(spy).toHaveBeenCalledWith(config.id, 'rm --force --stop'); + spy.mockRestore(); + }); + + // it('should re-genereate app.env file', async () => { + // // arrange + // const config = createAppConfig(); + // }); + + it('should handle errors gracefully', async () => { + // arrange + const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.reject(new Error('test'))); + const config = createAppConfig(); + + // act + const { message, success } = await appExecutors.stopApp(config.id, {}, true); + + // assert + expect(success).toBe(false); + expect(message).toBe('test'); + spy.mockRestore(); + }); + }); +}); diff --git a/packages/cli/src/executors/app/__tests__/app.helpers.test.ts b/packages/cli/src/executors/app/__tests__/app.helpers.test.ts new file mode 100644 index 00000000..fdb862d3 --- /dev/null +++ b/packages/cli/src/executors/app/__tests__/app.helpers.test.ts @@ -0,0 +1,243 @@ +import fs from 'fs'; +import { describe, it, expect } from 'vitest'; +import { faker } from '@faker-js/faker'; +import { copyDataDir, generateEnvFile } from '../app.helpers'; +import { createAppConfig } from '@/tests/apps.factory'; +import { getAppEnvMap } from '../env.helpers'; +import { getEnv } from '@/utils/environment/environment'; +import { pathExists } from '@/utils/fs-helpers'; + +const { rootFolderHost, storagePath } = getEnv(); + +describe('app helpers', () => { + describe('Test: generateEnvFile()', () => { + it('should throw an error if the app has an invalid config.json file', async () => { + // arrange + const appConfig = createAppConfig(); + await fs.promises.writeFile(`${rootFolderHost}/apps/${appConfig.id}/config.json`, '{}'); + + // act & assert + expect(generateEnvFile(appConfig.id, {})).rejects.toThrowError(`App ${appConfig.id} has invalid config.json file`); + }); + + it('Should generate an env file', async () => { + // arrange + const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST_FIELD', type: 'text', label: 'test', required: true }] }); + const fakevalue = faker.string.alphanumeric(10); + + // act + await generateEnvFile(appConfig.id, { TEST_FIELD: fakevalue }); + const envmap = await getAppEnvMap(appConfig.id); + + // assert + expect(envmap.get('TEST_FIELD')).toBe(fakevalue); + }); + + it('Should automatically generate value for random field', async () => { + // arrange + const appConfig = createAppConfig({ form_fields: [{ env_variable: 'RANDOM_FIELD', type: 'random', label: 'test', min: 32, max: 32, required: true }] }); + + // act + await generateEnvFile(appConfig.id, {}); + const envmap = await getAppEnvMap(appConfig.id); + + // assert + expect(envmap.get('RANDOM_FIELD')).toBeDefined(); + expect(envmap.get('RANDOM_FIELD')).toHaveLength(32); + }); + + it('Should not re-generate random field if it already exists', async () => { + // arrange + const appConfig = createAppConfig({ form_fields: [{ env_variable: 'RANDOM_FIELD', type: 'random', label: 'test', min: 32, max: 32, required: true }] }); + const randomField = faker.string.alphanumeric(32); + await fs.promises.mkdir(`${rootFolderHost}/app-data/${appConfig.id}`, { recursive: true }); + await fs.promises.writeFile(`${rootFolderHost}/app-data/${appConfig.id}/app.env`, `RANDOM_FIELD=${randomField}`); + + // act + await generateEnvFile(appConfig.id, {}); + const envmap = await getAppEnvMap(appConfig.id); + + // assert + expect(envmap.get('RANDOM_FIELD')).toBe(randomField); + }); + + it('Should throw an error if required field is not provided', async () => { + // arrange + const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST_FIELD', type: 'text', label: 'test', required: true }] }); + + // act & assert + await expect(generateEnvFile(appConfig.id, {})).rejects.toThrowError(); + }); + + it('Should throw an error if app does not exist', async () => { + // act & assert + await expect(generateEnvFile('non-existing-app', {})).rejects.toThrowError(); + }); + + it('Should add APP_EXPOSED to env file if domain is provided and app is exposed', async () => { + // arrange + const domain = faker.internet.domainName(); + const appConfig = createAppConfig({}); + + // act + await generateEnvFile(appConfig.id, { domain, exposed: true }); + const envmap = await getAppEnvMap(appConfig.id); + + // assert + expect(envmap.get('APP_EXPOSED')).toBe('true'); + expect(envmap.get('APP_DOMAIN')).toBe(domain); + }); + + it('Should not add APP_EXPOSED if domain is not provided', async () => { + // arrange + const appConfig = createAppConfig({}); + + // act + await generateEnvFile(appConfig.id, { exposed: true }); + const envmap = await getAppEnvMap(appConfig.id); + + // assert + expect(envmap.get('APP_EXPOSED')).toBeUndefined(); + }); + + it('Should not add APP_EXPOSED if app is not exposed', async () => { + // arrange + const domain = faker.internet.domainName(); + const appConfig = createAppConfig({}); + + // act + await generateEnvFile(appConfig.id, { domain }); + const envmap = await getAppEnvMap(appConfig.id); + + // assert + expect(envmap.get('APP_EXPOSED')).toBeUndefined(); + expect(envmap.get('APP_DOMAIN')).toBe(`localhost:${appConfig.port}`); + }); + + it('Should not re-create app-data folder if it already exists', async () => { + // arrange + const appConfig = createAppConfig({}); + await fs.promises.mkdir(`${rootFolderHost}/app-data/${appConfig.id}`, { recursive: true }); + + // act + await generateEnvFile(appConfig.id, {}); + const envmap = await getAppEnvMap(appConfig.id); + + // assert + expect(envmap.get('APP_EXPOSED')).toBeUndefined(); + }); + + it('should generate vapid private and public keys if config has generate_vapid_keys set to true', async () => { + // arrange + const appConfig = createAppConfig({ generate_vapid_keys: true }); + + // act + await generateEnvFile(appConfig.id, {}); + const envmap = await getAppEnvMap(appConfig.id); + + // assert + expect(envmap.get('VAPID_PRIVATE_KEY')).toBeDefined(); + expect(envmap.get('VAPID_PUBLIC_KEY')).toBeDefined(); + }); + + it('should not generate vapid private and public keys if config has generate_vapid_keys set to false', async () => { + // arrange + const appConfig = createAppConfig({ generate_vapid_keys: false }); + + // act + await generateEnvFile(appConfig.id, {}); + const envmap = await getAppEnvMap(appConfig.id); + + // assert + expect(envmap.get('VAPID_PRIVATE_KEY')).toBeUndefined(); + expect(envmap.get('VAPID_PUBLIC_KEY')).toBeUndefined(); + }); + + it('should not re-generate vapid private and public keys if they already exist', async () => { + // arrange + const appConfig = createAppConfig({ generate_vapid_keys: true }); + + const vapidPrivateKey = faker.string.alphanumeric(32); + const vapidPublicKey = faker.string.alphanumeric(32); + + // act + await fs.promises.mkdir(`${rootFolderHost}/app-data/${appConfig.id}`, { recursive: true }); + await fs.promises.writeFile(`${rootFolderHost}/app-data/${appConfig.id}/app.env`, `VAPID_PRIVATE_KEY=${vapidPrivateKey}\nVAPID_PUBLIC_KEY=${vapidPublicKey}`); + await generateEnvFile(appConfig.id, {}); + const envmap = await getAppEnvMap(appConfig.id); + + // assert + expect(envmap.get('VAPID_PRIVATE_KEY')).toBe(vapidPrivateKey); + expect(envmap.get('VAPID_PUBLIC_KEY')).toBe(vapidPublicKey); + }); + }); + + describe('Test: copyDataDir()', () => { + it('should do nothing if app does not have a data dir', async () => { + // arrange + const appConfig = createAppConfig({}); + + // act + await copyDataDir(appConfig.id); + + // assert + expect(await pathExists(`${rootFolderHost}/apps/${appConfig.id}/data`)).toBe(false); + }); + + it('should copy data dir to app-data folder', async () => { + // arrange + const appConfig = createAppConfig({}); + const dataDir = `${rootFolderHost}/apps/${appConfig.id}/data`; + + await fs.promises.mkdir(dataDir, { recursive: true }); + await fs.promises.writeFile(`${dataDir}/test.txt`, 'test'); + + // act + await copyDataDir(appConfig.id); + + // assert + const appDataDir = `${storagePath}/app-data/${appConfig.id}`; + expect(await fs.promises.readFile(`${appDataDir}/data/test.txt`, 'utf8')).toBe('test'); + }); + + it('should copy folders recursively', async () => { + // arrange + const appConfig = createAppConfig({}); + const dataDir = `${rootFolderHost}/apps/${appConfig.id}/data`; + + await fs.promises.mkdir(dataDir, { recursive: true }); + + const subDir = `${dataDir}/subdir/subsubdir`; + await fs.promises.mkdir(subDir, { recursive: true }); + + await fs.promises.writeFile(`${subDir}/test.txt`, 'test'); + await fs.promises.writeFile(`${dataDir}/test.txt`, 'test'); + + // act + await copyDataDir(appConfig.id); + + // assert + const appDataDir = `${storagePath}/app-data/${appConfig.id}`; + expect(await fs.promises.readFile(`${appDataDir}/data/subdir/subsubdir/test.txt`, 'utf8')).toBe('test'); + expect(await fs.promises.readFile(`${appDataDir}/data/test.txt`, 'utf8')).toBe('test'); + }); + + it('should replace the content of .template files with the content of the app.env file', async () => { + // arrange + const appConfig = createAppConfig({}); + const dataDir = `${rootFolderHost}/apps/${appConfig.id}/data`; + const appDataDir = `${storagePath}/app-data/${appConfig.id}`; + + await fs.promises.mkdir(dataDir, { recursive: true }); + await fs.promises.mkdir(appDataDir, { recursive: true }); + await fs.promises.writeFile(`${dataDir}/test.txt.template`, '{{TEST_VAR}}'); + await fs.promises.writeFile(`${appDataDir}/app.env`, 'TEST_VAR=test'); + + // act + await copyDataDir(appConfig.id); + + // assert + expect(await fs.promises.readFile(`${appDataDir}/data/test.txt`, 'utf8')).toBe('test'); + }); + }); +}); diff --git a/packages/cli/src/executors/app/app.executors.ts b/packages/cli/src/executors/app/app.executors.ts new file mode 100644 index 00000000..8ec0cd7c --- /dev/null +++ b/packages/cli/src/executors/app/app.executors.ts @@ -0,0 +1,245 @@ +import { appInfoSchema, createLogger } from '@runtipi/shared'; +import fs from 'fs'; +import path from 'path'; +import { promisify } from 'util'; +import { exec } from 'child_process'; +import { getEnv } from '@/utils/environment/environment'; +import { pathExists } from '@/utils/fs-helpers'; +import { compose } from '@/utils/docker-helpers'; +import { copyDataDir, generateEnvFile } from './app.helpers'; + +const execAsync = promisify(exec); + +export class AppExecutors { + private readonly rootFolderHost: string; + + private readonly storagePath: string; + + private readonly appsRepoId: string; + + private readonly logger; + + constructor() { + const { rootFolderHost, storagePath, appsRepoId } = getEnv(); + this.rootFolderHost = rootFolderHost; + this.storagePath = storagePath; + this.appsRepoId = appsRepoId; + this.logger = createLogger('app-executors', path.join(rootFolderHost, 'logs')); + } + + private handleAppError = (err: unknown) => { + if (err instanceof Error) { + this.logger.error(`An error occurred: ${err.message}`); + return { success: false, message: err.message }; + } + + return { success: false, message: `An error occurred: ${err}` }; + }; + + private getAppPaths = (appId: string) => { + const appDataDirPath = path.join(this.storagePath, 'app-data', appId); + const appDirPath = path.join(this.rootFolderHost, 'apps', appId); + const configJsonPath = path.join(appDirPath, 'config.json'); + const repoPath = path.join(this.rootFolderHost, 'repos', this.appsRepoId, 'apps', appId); + + return { appDataDirPath, appDirPath, configJsonPath, repoPath }; + }; + + private ensurePermissions = async (appId: string) => { + const { appDataDirPath, configJsonPath } = this.getAppPaths(appId); + if (!(await pathExists(appDataDirPath))) { + this.logger.info(`Creating app ${appId} data dir`); + await fs.promises.mkdir(appDataDirPath, { recursive: true }); + } + + // Check if app requires special uid and gid + if (await pathExists(configJsonPath)) { + const config = appInfoSchema.parse(JSON.parse(await fs.promises.readFile(configJsonPath, 'utf-8'))); + const { uid, gid } = config; + + if (uid && gid) { + this.logger.info(`Setting uid and gid to ${uid}:${gid}`); + await execAsync(`chown -R' ${uid}:${gid} ${path.join(appDataDirPath, 'data')}`); + } + } + + // Remove all .gitkeep files from app data dir + await execAsync(`find ${appDataDirPath} -name '.gitkeep' -exec rm -f {} \\;`); + await execAsync(`chmod -R a+rwx ${appDataDirPath}`); + }; + + /** + * Given an app id, ensures that the app folder exists in the apps folder + * If not, copies the app folder from the repo + * @param {string} appId - App id + */ + private ensureAppDir = async (appId: string) => { + const { appDirPath, repoPath } = this.getAppPaths(appId); + const dockerFilePath = path.join(this.rootFolderHost, 'apps', appId, 'docker-compose.yml'); + + if (!(await pathExists(dockerFilePath))) { + // delete eventual app folder if exists + this.logger.info(`Deleting app ${appId} folder if exists`); + await fs.promises.rm(appDirPath, { recursive: true, force: true }); + + // Copy app folder from repo + this.logger.info(`Copying app ${appId} from repo ${getEnv().appsRepoId}`); + await fs.promises.cp(repoPath, appDirPath, { recursive: true }); + } + }; + + /** + * Install an app from the repo + * @param {string} appId - The id of the app to install + * @param {Record} config - The config of the app + */ + public installApp = async (appId: string, config: Record) => { + try { + const { appDirPath, repoPath, appDataDirPath } = this.getAppPaths(appId); + this.logger.info(`Installing app ${appId}`); + + // Check if app exists in repo + const apps = await fs.promises.readdir(path.join(this.rootFolderHost, 'repos', this.appsRepoId, 'apps')); + + if (!apps.includes(appId)) { + this.logger.error(`App ${appId} not found in repo ${this.appsRepoId}`); + return { success: false, message: `App ${appId} not found in repo ${this.appsRepoId}` }; + } + + // Delete app folder if exists + this.logger.info(`Deleting folder ${appDirPath} if exists`); + await fs.promises.rm(appDirPath, { recursive: true, force: true }); + + // Create app folder + this.logger.info(`Creating folder ${appDirPath}`); + await fs.promises.mkdir(appDirPath, { recursive: true }); + + // Copy app folder from repo + this.logger.info(`Copying folder ${repoPath} to ${appDirPath}`); + await fs.promises.cp(repoPath, appDirPath, { recursive: true }); + + // Create folder app-data folder + this.logger.info(`Creating folder ${appDataDirPath}`); + await fs.promises.mkdir(appDataDirPath, { recursive: true }); + + // Create app.env file + this.logger.info(`Creating app.env file for app ${appId}`); + await generateEnvFile(appId, config); + + // Copy data dir + this.logger.info(`Copying data dir for app ${appId}`); + if (!(await pathExists(`${appDataDirPath}/data`))) { + await copyDataDir(appId); + } + + // run docker-compose up + this.logger.info(`Running docker-compose up for app ${appId}`); + await compose(appId, 'up -d'); + + this.logger.info(`Docker-compose up for app ${appId} finished`); + return { success: true, message: `App ${appId} installed successfully` }; + } catch (err) { + return this.handleAppError(err); + } + }; + + /** + * Stops an app + * @param {string} appId - The id of the app to stop + * @param {Record} config - The config of the app + */ + public stopApp = async (appId: string, config: Record, skipEnvGeneration = false) => { + try { + this.logger.info(`Stopping app ${appId}`); + + this.logger.info(`Regenerating app.env file for app ${appId}`); + await this.ensureAppDir(appId); + + if (!skipEnvGeneration) { + await generateEnvFile(appId, config); + } + await compose(appId, 'rm --force --stop'); + + this.logger.info(`App ${appId} stopped`); + return { success: true, message: `App ${appId} stopped successfully` }; + } catch (err) { + return this.handleAppError(err); + } + }; + + public startApp = async (appId: string, config: Record) => { + try { + this.logger.info(`Starting app ${appId}`); + + this.logger.info(`Regenerating app.env file for app ${appId}`); + await this.ensureAppDir(appId); + await generateEnvFile(appId, config); + await compose(appId, 'up --detach --force-recreate --remove-orphans'); + + this.logger.info(`App ${appId} started`); + return { success: true, message: `App ${appId} started successfully` }; + } catch (err) { + return this.handleAppError(err); + } + }; + + public uninstallApp = async (appId: string, config: Record) => { + try { + const { appDirPath, appDataDirPath } = this.getAppPaths(appId); + this.logger.info(`Uninstalling app ${appId}`); + + this.logger.info(`Regenerating app.env file for app ${appId}`); + await this.ensureAppDir(appId); + await generateEnvFile(appId, config); + await compose(appId, 'down --remove-orphans --volumes --rmi all'); + + this.logger.info(`Deleting folder ${appDirPath}`); + await fs.promises.rm(appDirPath, { recursive: true, force: true }); + + this.logger.info(`Deleting folder ${appDataDirPath}`); + await fs.promises.rm(appDataDirPath, { recursive: true, force: true }); + + this.logger.info(`App ${appId} uninstalled`); + return { success: true, message: `App ${appId} uninstalled successfully` }; + } catch (err) { + return this.handleAppError(err); + } + }; + + public updateApp = async (appId: string, config: Record) => { + try { + const { appDirPath, repoPath } = this.getAppPaths(appId); + this.logger.info(`Updating app ${appId}`); + await this.ensureAppDir(appId); + await generateEnvFile(appId, config); + + await compose(appId, 'up --detach --force-recreate --remove-orphans'); + await compose(appId, 'down --rmi all --remove-orphans'); + + this.logger.info(`Deleting folder ${appDirPath}`); + await fs.promises.rm(appDirPath, { recursive: true, force: true }); + + this.logger.info(`Copying folder ${repoPath} to ${appDirPath}`); + await fs.promises.cp(repoPath, appDirPath, { recursive: true }); + + await this.ensurePermissions(appId); + + await compose(appId, 'pull'); + + return { success: true, message: `App ${appId} updated successfully` }; + } catch (err) { + return this.handleAppError(err); + } + }; + + public regenerateAppEnv = async (appId: string, config: Record) => { + try { + this.logger.info(`Regenerating app.env file for app ${appId}`); + await this.ensureAppDir(appId); + await generateEnvFile(appId, config); + return { success: true, message: `App ${appId} env file regenerated successfully` }; + } catch (err) { + return this.handleAppError(err); + } + }; +} diff --git a/packages/cli/src/executors/app/app.helpers.ts b/packages/cli/src/executors/app/app.helpers.ts new file mode 100644 index 00000000..3c34fadf --- /dev/null +++ b/packages/cli/src/executors/app/app.helpers.ts @@ -0,0 +1,189 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { appInfoSchema } from '@runtipi/shared'; +import { getEnv } from '@/utils/environment/environment'; +import { envMapToString, envStringToMap, generateVapidKeys, getAppEnvMap } from './env.helpers'; +import { pathExists } from '@/utils/fs-helpers'; + +/** + * This function generates a random string of the provided length by using the SHA-256 hash algorithm. + * It takes the provided name and a seed value, concatenates them, and uses them as input for the hash algorithm. + * It then returns a substring of the resulting hash of the provided length. + * + * @param {string} name - A name used as input for the hash algorithm. + * @param {number} length - The desired length of the random string. + */ +const getEntropy = async (name: string, length: number) => { + const hash = crypto.createHash('sha256'); + const seed = await fs.promises.readFile(path.join(getEnv().rootFolderHost, 'state', 'seed')); + + hash.update(name + seed.toString()); + return hash.digest('hex').substring(0, length); +}; + +/** + * This function generates an env file for the provided app. + * It reads the config.json file for the app, parses it, + * and uses the app's form fields and domain to generate the env file + * if the app is exposed and has a domain set, it adds the domain to the env file, + * otherwise, it adds the internal IP address to the env file + * It also creates the app-data folder for the app if it does not exist + * + * @param {string} appId - The id of the app to generate the env file for. + * @param {Record} config - The config object for the app. + * @throws Will throw an error if the app has an invalid config.json file or if a required variable is missing. + */ +export const generateEnvFile = async (appId: string, config: Record) => { + const { rootFolderHost, storagePath, internalIp } = getEnv(); + + const configFile = await fs.promises.readFile(path.join(rootFolderHost, 'apps', appId, 'config.json')); + const parsedConfig = appInfoSchema.safeParse(JSON.parse(configFile.toString())); + + if (!parsedConfig.success) { + throw new Error(`App ${appId} has invalid config.json file`); + } + + const baseEnvFile = await fs.promises.readFile(path.join(rootFolderHost, '.env')); + const envMap = envStringToMap(baseEnvFile.toString()); + + // Default always present env variables + envMap.set('APP_PORT', String(parsedConfig.data.port)); + envMap.set('APP_ID', appId); + envMap.set('ROOT_FOLDER_HOST', rootFolderHost); + envMap.set('APP_DATA_DIR', path.join(storagePath, 'app-data', appId)); + + const existingEnvMap = await getAppEnvMap(appId); + + if (parsedConfig.data.generate_vapid_keys) { + if (existingEnvMap.has('VAPID_PUBLIC_KEY') && existingEnvMap.has('VAPID_PRIVATE_KEY')) { + envMap.set('VAPID_PUBLIC_KEY', existingEnvMap.get('VAPID_PUBLIC_KEY') as string); + envMap.set('VAPID_PRIVATE_KEY', existingEnvMap.get('VAPID_PRIVATE_KEY') as string); + } else { + const vapidKeys = generateVapidKeys(); + envMap.set('VAPID_PUBLIC_KEY', vapidKeys.publicKey); + envMap.set('VAPID_PRIVATE_KEY', vapidKeys.privateKey); + } + } + + await Promise.all( + parsedConfig.data.form_fields.map(async (field) => { + const formValue = config[field.env_variable]; + const envVar = field.env_variable; + + if (formValue || typeof formValue === 'boolean') { + envMap.set(envVar, String(formValue)); + } else if (field.type === 'random') { + if (existingEnvMap.has(envVar)) { + envMap.set(envVar, existingEnvMap.get(envVar) as string); + } else { + const length = field.min || 32; + const randomString = await getEntropy(field.env_variable, length); + + envMap.set(envVar, randomString); + } + } else if (field.required) { + throw new Error(`Variable ${field.label || field.env_variable} is required`); + } + }), + ); + + if (config.exposed && config.domain && typeof config.domain === 'string') { + envMap.set('APP_EXPOSED', 'true'); + envMap.set('APP_DOMAIN', config.domain); + envMap.set('APP_PROTOCOL', 'https'); + envMap.set('APP_HOST', config.domain); + } else { + envMap.set('APP_DOMAIN', `${internalIp}:${parsedConfig.data.port}`); + envMap.set('APP_HOST', internalIp); + } + + // Create app-data folder if it doesn't exist + const appDataDirectoryExists = await fs.promises.stat(path.join(storagePath, 'app-data', appId)).catch(() => false); + if (!appDataDirectoryExists) { + await fs.promises.mkdir(path.join(storagePath, 'app-data', appId), { recursive: true }); + } + + await fs.promises.writeFile(path.join(storagePath, 'app-data', appId, 'app.env'), envMapToString(envMap)); +}; + +/** + * Given a template and a map of variables, this function replaces all instances of the variables in the template with their values. + * + * @param {string} template - The template to be rendered. + * @param {Map} envMap - The map of variables and their values. + */ +const renderTemplate = (template: string, envMap: Map) => { + let renderedTemplate = template; + + envMap.forEach((value, key) => { + renderedTemplate = renderedTemplate.replace(new RegExp(`{{${key}}}`, 'g'), value); + }); + + return renderedTemplate; +}; + +/** + * Given an app, this function copies the app's data directory to the app-data folder. + * If a file with an extension of .template is found, it will be copied as a file without the .template extension and the template variables will be replaced + * by the values in the app's env file. + * + * @param {string} id - The id of the app. + */ +export const copyDataDir = async (id: string) => { + const { rootFolderHost, storagePath } = getEnv(); + + const envMap = await getAppEnvMap(id); + + // return if app does not have a data directory + if (!(await pathExists(`${rootFolderHost}/apps/${id}/data`))) { + return; + } + + // Create app-data folder if it doesn't exist + if (!(await pathExists(`${storagePath}/app-data/${id}/data`))) { + await fs.promises.mkdir(`${storagePath}/app-data/${id}/data`, { recursive: true }); + } + + const dataDir = await fs.promises.readdir(`${rootFolderHost}/apps/${id}/data`); + + const processFile = async (file: string) => { + if (file.endsWith('.template')) { + const template = await fs.promises.readFile(`${rootFolderHost}/apps/${id}/data/${file}`, 'utf-8'); + const renderedTemplate = renderTemplate(template, envMap); + + await fs.promises.writeFile(`${storagePath}/app-data/${id}/data/${file.replace('.template', '')}`, renderedTemplate); + } else { + await fs.promises.copyFile(`${rootFolderHost}/apps/${id}/data/${file}`, `${storagePath}/app-data/${id}/data/${file}`); + } + }; + + const processDir = async (p: string) => { + await fs.promises.mkdir(`${storagePath}/app-data/${id}/data/${p}`, { recursive: true }); + const files = await fs.promises.readdir(`${rootFolderHost}/apps/${id}/data/${p}`); + + await Promise.all( + files.map(async (file) => { + const fullPath = `${rootFolderHost}/apps/${id}/data/${p}/${file}`; + + if ((await fs.promises.lstat(fullPath)).isDirectory()) { + await processDir(`${p}/${file}`); + } else { + await processFile(`${p}/${file}`); + } + }), + ); + }; + + await Promise.all( + dataDir.map(async (file) => { + const fullPath = `${rootFolderHost}/apps/${id}/data/${file}`; + + if ((await fs.promises.lstat(fullPath)).isDirectory()) { + await processDir(file); + } else { + await processFile(file); + } + }), + ); +}; diff --git a/packages/cli/src/executors/app/env.helpers.ts b/packages/cli/src/executors/app/env.helpers.ts new file mode 100644 index 00000000..bff60291 --- /dev/null +++ b/packages/cli/src/executors/app/env.helpers.ts @@ -0,0 +1,68 @@ +import webpush from 'web-push'; +import fs from 'fs'; +import path from 'path'; +import { getEnv } from '@/utils/environment/environment'; + +/** + * Convert a string of environment variables to a Map + * + * @param {string} envString - String of environment variables + */ +export const envStringToMap = (envString: string) => { + const envMap = new Map(); + const envArray = envString.split('\n'); + + envArray.forEach((env) => { + const [key, value] = env.split('='); + if (key && value) { + envMap.set(key, value); + } + }); + + return envMap; +}; + +/** + * Convert a Map of environment variables to a valid string of environment variables + * that can be used in a .env file + * + * @param {Map} envMap - Map of environment variables + */ +export const envMapToString = (envMap: Map) => { + const envArray = Array.from(envMap).map(([key, value]) => `${key}=${value}`); + return envArray.join('\n'); +}; + +/** + * This function reads the env file for the app with the provided id and returns a Map containing the key-value pairs of the environment variables. + * It reads the app.env file, splits it into individual environment variables, and stores them in a Map, with the environment variable name as the key and its value as the value. + * + * @param {string} appId - App ID + */ +export const getAppEnvMap = async (appId: string) => { + try { + const envFile = await fs.promises.readFile(path.join(getEnv().storagePath, 'app-data', appId, 'app.env')); + const envVars = envFile.toString().split('\n'); + const envVarsMap = new Map(); + + envVars.forEach((envVar) => { + const [key, value] = envVar.split('='); + if (key && value) envVarsMap.set(key, value); + }); + + return envVarsMap; + } catch (e) { + return new Map(); + } +}; + +/** + * Generate VAPID keys + */ +export const generateVapidKeys = () => { + const vapidKeys = webpush.generateVAPIDKeys(); + return { + publicKey: vapidKeys.publicKey, + privateKey: vapidKeys.privateKey, + }; +}; diff --git a/packages/cli/src/executors/index.ts b/packages/cli/src/executors/index.ts new file mode 100644 index 00000000..d6d749fd --- /dev/null +++ b/packages/cli/src/executors/index.ts @@ -0,0 +1,3 @@ +export { AppExecutors } from './app/app.executors'; +export { RepoExecutors } from './repo/repo.executors'; +export { SystemExecutors } from './system/system.executors'; diff --git a/packages/cli/src/executors/repo/repo.executors.ts b/packages/cli/src/executors/repo/repo.executors.ts new file mode 100644 index 00000000..be677cbc --- /dev/null +++ b/packages/cli/src/executors/repo/repo.executors.ts @@ -0,0 +1,96 @@ +import { getEnv } from 'src/utils/environment/environment'; +import { createLogger } from '@runtipi/shared'; +import path from 'path'; +import { promisify } from 'util'; +import { exec } from 'child_process'; +import { pathExists } from '@/utils/fs-helpers'; +import { getRepoHash } from './repo.helpers'; + +const execAsync = promisify(exec); + +export class RepoExecutors { + private readonly rootFolderHost: string; + + private readonly logger; + + constructor() { + const { rootFolderHost } = getEnv(); + this.rootFolderHost = rootFolderHost; + this.logger = createLogger('repo-executors', path.join(rootFolderHost, 'logs')); + } + + /** + * Error handler for repo operations + * @param {unknown} err + */ + private handleRepoError = (err: unknown) => { + if (err instanceof Error) { + this.logger.error(`An error occurred: ${err.message}`); + return { success: false, message: err.message }; + } + + return { success: false, message: `An error occurred: ${err}` }; + }; + + /** + * Given a repo url, clone it to the repos folder if it doesn't exist + * + * @param {string} repoUrl + */ + public cloneRepo = async (repoUrl: string) => { + try { + const repoHash = getRepoHash(repoUrl); + const repoPath = path.join(this.rootFolderHost, 'repos', repoHash); + + if (await pathExists(repoPath)) { + this.logger.info(`Repo ${repoUrl} already exists`); + return { success: true, message: '' }; + } + + this.logger.info(`Cloning repo ${repoUrl} to ${repoPath}`); + + const { stdout, stderr } = await execAsync(`git clone ${repoUrl} ${repoPath}`); + + if (stderr) { + this.logger.error(`Error cloning repo ${repoUrl}: ${stderr}`); + return { success: false, message: stderr }; + } + + this.logger.info(`Cloned repo ${repoUrl} to ${repoPath}`); + return { success: true, message: stdout }; + } catch (err) { + return this.handleRepoError(err); + } + }; + + /** + * Given a repo url, pull it to the repos folder if it exists + * + * @param {string} repoUrl + */ + public pullRepo = async (repoUrl: string) => { + try { + const repoHash = getRepoHash(repoUrl); + const repoPath = path.join(this.rootFolderHost, 'repos', repoHash); + + if (!(await pathExists(repoPath))) { + this.logger.info(`Repo ${repoUrl} does not exist`); + return { success: false, message: `Repo ${repoUrl} does not exist` }; + } + + this.logger.info(`Pulling repo ${repoUrl} to ${repoPath}`); + + const { stdout, stderr } = await execAsync(`git -C ${repoPath} pull`); + + if (stderr) { + this.logger.error(`Error pulling repo ${repoUrl}: ${stderr}`); + return { success: false, message: stderr }; + } + + this.logger.info(`Pulled repo ${repoUrl} to ${repoPath}`); + return { success: true, message: stdout }; + } catch (err) { + return this.handleRepoError(err); + } + }; +} diff --git a/packages/cli/src/executors/repo/repo.helpers.ts b/packages/cli/src/executors/repo/repo.helpers.ts new file mode 100644 index 00000000..4032bc46 --- /dev/null +++ b/packages/cli/src/executors/repo/repo.helpers.ts @@ -0,0 +1,12 @@ +import crypto from 'crypto'; + +/** + * Given a repo url, return a hash of it to be used as a folder name + * + * @param {string} repoUrl + */ +export const getRepoHash = (repoUrl: string) => { + const hash = crypto.createHash('sha256'); + hash.update(repoUrl); + return hash.digest('hex'); +}; diff --git a/packages/cli/src/executors/system/system.executors.ts b/packages/cli/src/executors/system/system.executors.ts new file mode 100644 index 00000000..a71d050a --- /dev/null +++ b/packages/cli/src/executors/system/system.executors.ts @@ -0,0 +1,300 @@ +import fs from 'fs'; +import cliProgress from 'cli-progress'; +import semver from 'semver'; +import axios from 'axios'; +import boxen from 'boxen'; +import path from 'path'; +import { promisify } from 'util'; +import { exec, spawn } from 'child_process'; +import si from 'systeminformation'; +import { createLogger } from '@runtipi/shared'; +import { Stream } from 'stream'; +import { AppExecutors } from '../app/app.executors'; +import { copySystemFiles, generateSystemEnvFile, generateTlsCertificates } from './system.helpers'; +import { TerminalSpinner } from '@/utils/logger/terminal-spinner'; +import { pathExists } from '@/utils/fs-helpers'; +import { getEnv } from '@/utils/environment/environment'; + +const logger = createLogger('system-executors', path.join(process.cwd(), 'logs')); + +const execAsync = promisify(exec); + +export class SystemExecutors { + private readonly rootFolder: string; + + private readonly envFile: string; + + constructor() { + this.rootFolder = process.cwd(); + + this.envFile = path.join(this.rootFolder, '.env'); + } + + private handleSystemError = (err: unknown) => { + if (err instanceof Error) { + logger.error(`An error occurred: ${err.message}`); + return { success: false, message: err.message }; + } + + return { success: false, message: `An error occurred: ${err}` }; + }; + + private getSystemLoad = async () => { + const { currentLoad } = await si.currentLoad(); + const mem = await si.mem(); + const [disk0] = await si.fsSize(); + + return { + cpu: { load: currentLoad }, + memory: { total: mem.total, used: mem.used, available: mem.available }, + disk: { total: disk0?.size, used: disk0?.used, available: disk0?.available }, + }; + }; + + public systemInfo = async () => { + try { + const { rootFolderHost } = getEnv(); + const systemLoad = await this.getSystemLoad(); + + await fs.promises.writeFile(path.join(rootFolderHost, 'state', 'system-info.json'), JSON.stringify(systemLoad, null, 2)); + await fs.promises.chmod(path.join(rootFolderHost, 'state', 'system-info.json'), 0o777); + + return { success: true, message: '' }; + } catch (e) { + return this.handleSystemError(e); + } + }; + + /** + * This method will stop Tipi + * It will stop all the apps and then stop the main containers. + */ + public stop = async () => { + try { + const spinner = new TerminalSpinner('Stopping Tipi...'); + + if (await pathExists(path.join(this.rootFolder, 'apps'))) { + const apps = await fs.promises.readdir(path.join(this.rootFolder, 'apps')); + const appExecutor = new AppExecutors(); + + await Promise.all( + apps.map(async (app) => { + const appSpinner = new TerminalSpinner(`Stopping ${app}...`); + appSpinner.start(); + await appExecutor.stopApp(app, {}, true); + appSpinner.done(`${app} stopped`); + }), + ); + } + + spinner.setMessage('Stopping containers...'); + spinner.start(); + await execAsync('docker compose down --remove-orphans --rmi local'); + spinner.done('Tipi successfully stopped'); + + return { success: true, message: 'Tipi stopped' }; + } catch (e) { + return this.handleSystemError(e); + } + }; + + /** + * This method will start Tipi. + * It will copy the system files, generate the system env file, pull the images and start the containers. + */ + public start = async () => { + try { + const spinner = new TerminalSpinner('Starting Tipi...'); + + spinner.start(); + spinner.setMessage('Copying system files...'); + await copySystemFiles(); + spinner.done('System files copied'); + + spinner.setMessage('Generating system env file...'); + spinner.start(); + const envMap = await generateSystemEnvFile(); + spinner.done('System env file generated'); + + // Stop and Remove container tipi if exists + spinner.setMessage('Stopping and removing containers...'); + spinner.start(); + await execAsync('docker rm -f tipi-db'); + await execAsync('docker rm -f tipi-redis'); + await execAsync('docker rm -f dashboard'); + await execAsync('docker rm -f reverse-proxy'); + spinner.done('Containers stopped and removed'); + + // Pull images + spinner.setMessage('Pulling images...'); + spinner.start(); + await execAsync(`docker compose --env-file "${this.envFile}" pull`); + spinner.done('Images pulled'); + + // Start containers + spinner.setMessage('Starting containers...'); + spinner.start(); + await execAsync(`docker compose --env-file "${this.envFile}" up --detach --remove-orphans --build`); + spinner.done('Containers started'); + + // start watcher cli in the background + spinner.setMessage('Starting watcher...'); + spinner.start(); + + await generateTlsCertificates({ domain: envMap.get('LOCAL_DOMAIN') }); + + const out = fs.openSync('./logs/watcher.log', 'a'); + const err = fs.openSync('./logs/watcher.log', 'a'); + + const subprocess = spawn('./runtipi-cli', [process.argv[1] as string, 'watch'], { cwd: this.rootFolder, detached: true, stdio: ['ignore', out, err] }); + subprocess.unref(); + + spinner.done('Watcher started'); + + console.log( + boxen(`Visit: http://${envMap.get('INTERNAL_IP')}:${envMap.get('NGINX_PORT')} to access the dashboard\n\nFind documentation and guides at: https://runtipi.io`, { + title: 'Tipi successfully started 🎉', + titleAlignment: 'center', + padding: 1, + borderStyle: 'double', + borderColor: 'green', + margin: { top: 1 }, + }), + ); + + return { success: true, message: 'Tipi started' }; + } catch (e) { + return this.handleSystemError(e); + } + }; + + /** + * This method will stop and start Tipi. + */ + public restart = async () => { + try { + await this.stop(); + await this.start(); + return { success: true, message: '' }; + } catch (e) { + return this.handleSystemError(e); + } + }; + + /** + * This method will create a password change request file in the state folder. + */ + public resetPassword = async () => { + const { rootFolderHost } = getEnv(); + await fs.promises.writeFile(path.join(rootFolderHost, 'state', 'password-change-request'), ''); + }; + + /** + * Given a target version, this method will download the corresponding release from GitHub and replace the current + * runtipi-cli binary with the new one. + * @param {string} target + */ + public update = async (target: string) => { + const spinner = new TerminalSpinner('Evaluating target version...'); + try { + spinner.start(); + let targetVersion = target; + + if (!targetVersion || targetVersion === 'latest') { + spinner.setMessage('Fetching latest version...'); + const { data } = await axios.get<{ tag_name: string }>('https://api.github.com/repos/meienberger/runtipi/releases'); + targetVersion = data.tag_name; + } + + if (!semver.valid(targetVersion)) { + spinner.fail(`Invalid version: ${targetVersion}`); + throw new Error(`Invalid version: ${targetVersion}`); + } + + const { rootFolderHost, arch } = getEnv(); + + let assetName = 'runtipi-cli-linux-x64'; + if (arch === 'arm64') { + assetName = 'runtipi-cli-linux-arm64'; + } + + const fileName = `runtipi-cli-${targetVersion}`; + const savePath = path.join(rootFolderHost, fileName); + const fileUrl = `https://github.com/meienberger/runtipi/releases/download/${targetVersion}/${assetName}`; + + spinner.done(`Target version: ${targetVersion}`); + spinner.done(`Download url: ${fileUrl}`); + + await this.stop(); + + console.log(`Downloading Tipi ${targetVersion}...`); + + const bar = new cliProgress.SingleBar({}, cliProgress.Presets.rect); + bar.start(100, 0); + + await new Promise((resolve, reject) => { + axios({ + method: 'GET', + url: fileUrl, + responseType: 'stream', + onDownloadProgress: (progress) => { + bar.update(Math.round((progress.loaded / (progress.total || 0)) * 100)); + }, + }).then((response) => { + const writer = fs.createWriteStream(savePath); + response.data.pipe(writer); + + writer.on('error', (err) => { + bar.stop(); + spinner.fail(`\nFailed to download Tipi ${targetVersion}`); + reject(err); + }); + + writer.on('finish', () => { + bar.stop(); + resolve(''); + }); + }); + }).catch((e) => { + spinner.fail(`\nFailed to download Tipi ${targetVersion}. Please make sure this version exists on GitHub.`); + throw e; + }); + + spinner.done(`Tipi ${targetVersion} downloaded`); + await fs.promises.chmod(savePath, 0o755); + + spinner.setMessage('Replacing old cli...'); + spinner.start(); + + // Delete old cli + if (await pathExists(path.join(rootFolderHost, 'runtipi-cli'))) { + await fs.promises.unlink(path.join(rootFolderHost, 'runtipi-cli')); + } + + // Delete VERSION file + if (await pathExists(path.join(rootFolderHost, 'VERSION'))) { + await fs.promises.unlink(path.join(rootFolderHost, 'VERSION')); + } + + // Rename downloaded cli to runtipi-cli + await fs.promises.rename(savePath, path.join(rootFolderHost, 'runtipi-cli')); + spinner.done('Old cli replaced'); + + const childProcess = spawn('./runtipi-cli', [process.argv[1] as string, 'start']); + + childProcess.stdout.on('data', (data) => { + process.stdout.write(data); + }); + + childProcess.stderr.on('data', (data) => { + process.stderr.write(data); + }); + + return { success: true, message: 'Tipi updated' }; + } catch (e) { + spinner.fail('Tipi update failed, see logs for details'); + logger.error(e); + return this.handleSystemError(e); + } + }; +} diff --git a/packages/cli/src/executors/system/system.helpers.ts b/packages/cli/src/executors/system/system.helpers.ts new file mode 100644 index 00000000..06a2aaf6 --- /dev/null +++ b/packages/cli/src/executors/system/system.helpers.ts @@ -0,0 +1,262 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { envMapToString, envStringToMap, settingsSchema } from '@runtipi/shared'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import chalk from 'chalk'; +import { pathExists } from '@/utils/fs-helpers'; +import { getRepoHash } from '../repo/repo.helpers'; + +type EnvKeys = + | 'APPS_REPO_ID' + | 'APPS_REPO_URL' + | 'TZ' + | 'INTERNAL_IP' + | 'DNS_IP' + | 'ARCHITECTURE' + | 'TIPI_VERSION' + | 'JWT_SECRET' + | 'ROOT_FOLDER_HOST' + | 'NGINX_PORT' + | 'NGINX_PORT_SSL' + | 'DOMAIN' + | 'STORAGE_PATH' + | 'POSTGRES_PORT' + | 'POSTGRES_HOST' + | 'POSTGRES_DBNAME' + | 'POSTGRES_PASSWORD' + | 'POSTGRES_USERNAME' + | 'REDIS_HOST' + | 'LOCAL_DOMAIN' + | 'DEMO_MODE' + // eslint-disable-next-line @typescript-eslint/ban-types + | (string & {}); + +const execAsync = promisify(exec); + +const DEFAULT_REPO_URL = 'https://github.com/meienberger/runtipi-appstore'; + +/** + * Reads and returns the generated seed + */ +const getSeed = async () => { + const rootFolder = process.cwd(); + + const seedFilePath = path.join(rootFolder, 'state', 'seed'); + + if (!(await pathExists(seedFilePath))) { + throw new Error('Seed file not found'); + } + + const seed = await fs.promises.readFile(seedFilePath, 'utf-8'); + + return seed; +}; + +/** + * Derives a new entropy value from the provided entropy and the seed + * @param {string} entropy - The entropy value to derive from + */ +const deriveEntropy = async (entropy: string) => { + const seed = await getSeed(); + const hmac = crypto.createHmac('sha256', seed); + hmac.update(entropy); + + return hmac.digest('hex'); +}; + +/** + * Generates a random seed if it does not exist yet + */ +const generateSeed = async (rootFolder: string) => { + if (!(await pathExists(path.join(rootFolder, 'state', 'seed')))) { + const randomBytes = crypto.randomBytes(32); + const seed = randomBytes.toString('hex'); + + await fs.promises.writeFile(path.join(rootFolder, 'state', 'seed'), seed); + } +}; + +/** + * Will return the first internal IP address of the current system + */ +const getInternalIp = () => { + const interfaces = os.networkInterfaces(); + + for (let i = 0; i < Object.keys(interfaces).length; i += 1) { + const devName = Object.keys(interfaces)[i]; + const iface = interfaces[devName || '']; + + const length = iface?.length || 0; + for (let j = 0; j < length; j += 1) { + const alias = iface?.[j]; + + if (alias && alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) return alias.address; + } + } + + return '0.0.0.0'; +}; + +/** + * Returns the architecture of the current system + */ +const getArchitecture = () => { + const arch = os.arch(); + + if (arch === 'arm64') return 'arm64'; + if (arch === 'x64') return 'amd64'; + + throw new Error(`Unsupported architecture: ${arch}`); +}; + +/** + * Generates a valid .env file from the settings.json file + */ +export const generateSystemEnvFile = async () => { + const rootFolder = process.cwd(); + await fs.promises.mkdir(path.join(rootFolder, 'state'), { recursive: true }); + const settingsFilePath = path.join(rootFolder, 'state', 'settings.json'); + const envFilePath = path.join(rootFolder, '.env'); + + if (!(await pathExists(envFilePath))) { + await fs.promises.writeFile(envFilePath, ''); + } + + const envFile = await fs.promises.readFile(envFilePath, 'utf-8'); + const envMap: Map = envStringToMap(envFile); + + if (!(await pathExists(settingsFilePath))) { + await fs.promises.writeFile(settingsFilePath, JSON.stringify({})); + } + + const settingsFile = await fs.promises.readFile(settingsFilePath, 'utf-8'); + + const settings = settingsSchema.safeParse(JSON.parse(settingsFile)); + + if (!settings.success) { + throw new Error(`Invalid settings.json file: ${settings.error.message}`); + } + + await generateSeed(rootFolder); + + const { data } = settings; + + const jwtSecret = envMap.get('JWT_SECRET') || (await deriveEntropy('jwt_secret')); + const repoId = getRepoHash(data.appsRepoUrl || DEFAULT_REPO_URL); + const postgresPassword = envMap.get('POSTGRES_PASSWORD') || (await deriveEntropy('postgres_password')); + + const version = await fs.promises.readFile(path.join(rootFolder, 'VERSION'), 'utf-8'); + + envMap.set('APPS_REPO_ID', repoId); + envMap.set('APPS_REPO_URL', data.appsRepoUrl || DEFAULT_REPO_URL); + envMap.set('TZ', Intl.DateTimeFormat().resolvedOptions().timeZone); + envMap.set('INTERNAL_IP', data.listenIp || getInternalIp()); + envMap.set('DNS_IP', data.dnsIp || '9.9.9.9'); + envMap.set('ARCHITECTURE', getArchitecture()); + envMap.set('TIPI_VERSION', version); + envMap.set('JWT_SECRET', jwtSecret); + envMap.set('ROOT_FOLDER_HOST', rootFolder); + envMap.set('NGINX_PORT', String(data.port || 80)); + envMap.set('NGINX_PORT_SSL', String(data.sslPort || 443)); + envMap.set('DOMAIN', data.domain || 'example.com'); + envMap.set('STORAGE_PATH', data.storagePath || rootFolder); + envMap.set('POSTGRES_HOST', 'tipi-db'); + envMap.set('POSTGRES_DBNAME', 'tipi'); + envMap.set('POSTGRES_USERNAME', 'tipi'); + envMap.set('POSTGRES_PASSWORD', postgresPassword); + envMap.set('POSTGRES_PORT', String(5432)); + envMap.set('REDIS_HOST', 'tipi-redis'); + envMap.set('DEMO_MODE', String(data.demoMode || 'false')); + envMap.set('LOCAL_DOMAIN', data.localDomain || 'tipi.lan'); + envMap.set('NODE_ENV', 'production'); + + await fs.promises.writeFile(envFilePath, envMapToString(envMap)); + + return envMap; +}; + +/** + * Copies the system files from the assets folder to the current working directory + */ +export const copySystemFiles = async () => { + const assetsFolder = path.join('/snapshot', 'runtipi', 'packages', 'cli', 'assets'); + + // Copy docker-compose.yml file + await fs.promises.copyFile(path.join(assetsFolder, 'docker-compose.yml'), path.join(process.cwd(), 'docker-compose.yml')); + + // Copy VERSION file + await fs.promises.copyFile(path.join(assetsFolder, 'VERSION'), path.join(process.cwd(), 'VERSION')); + + // Copy traefik folder from assets + await fs.promises.mkdir(path.join(process.cwd(), 'traefik', 'dynamic'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'traefik', 'shared'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'traefik', 'tls'), { recursive: true }); + + await fs.promises.copyFile(path.join(assetsFolder, 'traefik', 'traefik.yml'), path.join(process.cwd(), 'traefik', 'traefik.yml')); + await fs.promises.copyFile(path.join(assetsFolder, 'traefik', 'dynamic', 'dynamic.yml'), path.join(process.cwd(), 'traefik', 'dynamic', 'dynamic.yml')); + + // Create base folders + await fs.promises.mkdir(path.join(process.cwd(), 'apps'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'app-data'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'state'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'repos'), { recursive: true }); + + // Create media folders + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'torrents', 'watch'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'torrents', 'complete'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'torrents', 'incomplete'), { recursive: true }); + + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'usenet', 'watch'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'usenet', 'complete'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'usenet', 'incomplete'), { recursive: true }); + + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'downloads', 'watch'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'downloads', 'complete'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'downloads', 'incomplete'), { recursive: true }); + + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'books'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'comics'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'movies'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'music'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'tv'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'podcasts'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'images'), { recursive: true }); + await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'roms'), { recursive: true }); +}; + +/** + * Given a domain, generates the TLS certificates for it to be used with Traefik + * + * @param {string} data.domain The domain to generate the certificates for + */ +export const generateTlsCertificates = async (data: { domain?: string }) => { + if (!data.domain) { + return; + } + + // If the certificate already exists, don't generate it again + if (await pathExists(path.join(process.cwd(), 'traefik', 'tls', `${data.domain}.txt`))) { + return; + } + + // Remove old certificates + if (await pathExists(path.join(process.cwd(), 'traefik', 'tls', 'cert.pem'))) { + await fs.promises.unlink(path.join(process.cwd(), 'traefik', 'tls', 'cert.pem')); + } + if (await pathExists(path.join(process.cwd(), 'traefik', 'tls', 'key.pem'))) { + await fs.promises.unlink(path.join(process.cwd(), 'traefik', 'tls', 'key.pem')); + } + + const subject = `/O=runtipi.io/OU=IT/CN=*.${data.domain}/emailAddress=webmaster@${data.domain}`; + const subjectAltName = `DNS:*.${data.domain},DNS:${data.domain}`; + + try { + await execAsync(`openssl req -x509 -newkey rsa:4096 -keyout traefik/tls/key.pem -out traefik/tls/cert.pem -days 365 -subj "${subject}" -addext "${subjectAltName}" -nodes`); + await fs.promises.writeFile(path.join(process.cwd(), 'traefik', 'tls', `${data.domain}.txt`), ''); + } catch (error) { + console.error(chalk.red('✗'), 'Failed to generate TLS certificates'); + } +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 00000000..cce72a1a --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import { program } from 'commander'; + +import chalk from 'chalk'; +import { description, version } from '../package.json'; +import { startWorker } from './services/watcher/watcher'; +import { SystemExecutors } from './executors'; + +const main = async () => { + program.description(description).version(version); + + program + .command('watch') + .description('Watcher script for events queue') + .action(async () => { + console.log('Starting watcher'); + startWorker(); + }); + + program + .command('start') + .description('Start tipi') + .action(async () => { + const systemExecutors = new SystemExecutors(); + await systemExecutors.start(); + }); + + program + .command('stop') + .description('Stop tipi') + .action(async () => { + const systemExecutors = new SystemExecutors(); + await systemExecutors.stop(); + }); + + program + .command('restart') + .description('Restart tipi') + .action(async () => { + const systemExecutors = new SystemExecutors(); + await systemExecutors.restart(); + }); + + program + .command('update') + .description('Update tipi') + .argument('', 'Target to update') + .action(async (target) => { + const systemExecutors = new SystemExecutors(); + await systemExecutors.update(target); + }); + + program + .command('reset-password') + .description('Reset password') + .action(async () => { + const systemExecutors = new SystemExecutors(); + await systemExecutors.resetPassword(); + console.log(chalk.green('✓'), 'Password reset request created. Head back to the dashboard to set a new password.'); + }); + + program.parse(process.argv); +}; + +try { + console.log(chalk.green('Welcome to Tipi CLI ✨')); + main(); +} catch (e) { + console.error('An error occurred:', e); +} diff --git a/packages/cli/src/services/watcher/watcher.ts b/packages/cli/src/services/watcher/watcher.ts new file mode 100644 index 00000000..0b749565 --- /dev/null +++ b/packages/cli/src/services/watcher/watcher.ts @@ -0,0 +1,118 @@ +import { eventSchema } from '@runtipi/shared'; +import { Worker } from 'bullmq'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { AppExecutors, RepoExecutors, SystemExecutors } from '@/executors'; + +const execAsync = promisify(exec); + +const runCommand = async (jobData: unknown) => { + const { installApp, startApp, stopApp, uninstallApp, updateApp, regenerateAppEnv } = new AppExecutors(); + const { cloneRepo, pullRepo } = new RepoExecutors(); + const { systemInfo, restart } = new SystemExecutors(); + + const event = eventSchema.safeParse(jobData); + + if (!event.success) { + throw new Error('Event is not valid'); + } + + const { data } = event; + + let success = false; + let message = `Event has invalid type or args ${JSON.stringify(data)}`; + + if (data.type === 'app') { + if (data.command === 'install') { + ({ success, message } = await installApp(data.appid, data.form)); + } + + if (data.command === 'stop') { + ({ success, message } = await stopApp(data.appid, data.form)); + } + + if (data.command === 'start') { + ({ success, message } = await startApp(data.appid, data.form)); + } + + if (data.command === 'uninstall') { + ({ success, message } = await uninstallApp(data.appid, data.form)); + } + + if (data.command === 'update') { + ({ success, message } = await updateApp(data.appid, data.form)); + } + + if (data.command === 'generate_env') { + ({ success, message } = await regenerateAppEnv(data.appid, data.form)); + } + } else if (data.type === 'repo') { + if (data.command === 'clone') { + ({ success, message } = await cloneRepo(data.url)); + } + + if (data.command === 'update') { + ({ success, message } = await pullRepo(data.url)); + } + } else if (data.type === 'system') { + if (data.command === 'system_info') { + ({ success, message } = await systemInfo()); + } + + if (data.command === 'restart') { + ({ success, message } = await restart()); + } + } + + return { success, message }; +}; + +const killOtherWorkers = async () => { + const { stdout } = await execAsync('ps aux | grep "index.js watch" | grep -v grep | awk \'{print $2}\''); + + const pids = stdout.split('\n').filter((pid: string) => pid !== ''); + + pids.forEach((pid) => { + if (pid === process.pid.toString()) { + console.log('Skipping killing current worker'); + return; + } + + console.log(`Killing worker with pid ${pid}`); + process.kill(Number(pid)); + }); +}; + +/** + * Start the worker for the events queue + */ +export const startWorker = async () => { + await killOtherWorkers(); + + const worker = new Worker( + 'events', + async (job) => { + console.log(`Processing job ${job.id} with data ${JSON.stringify(job.data)}`); + const { message, success } = await runCommand(job.data); + + return { success, stdout: message }; + }, + { connection: { host: '127.0.0.1', port: 6379 } }, + ); + + worker.on('ready', () => { + console.log('Worker is ready'); + }); + + worker.on('completed', (job) => { + console.log(`Job ${job.id} completed with result: ${JSON.stringify(job.returnvalue)}`); + }); + + worker.on('failed', (job) => { + console.error(`Job ${job?.id} failed with reason ${job?.failedReason}`); + }); + + worker.on('error', async (e) => { + console.error('An error occurred:', e); + }); +}; diff --git a/packages/cli/src/utils/docker-helpers/docker-helpers.ts b/packages/cli/src/utils/docker-helpers/docker-helpers.ts new file mode 100644 index 00000000..33d4b762 --- /dev/null +++ b/packages/cli/src/utils/docker-helpers/docker-helpers.ts @@ -0,0 +1,59 @@ +import path from 'path'; +import { promisify } from 'util'; +import { exec } from 'child_process'; +import { createLogger } from '@runtipi/shared'; +import { getEnv } from '../environment/environment'; +import { pathExists } from '../fs-helpers/fs-helpers'; + +const execAsync = promisify(exec); + +const logger = createLogger('docker-helpers', path.join(process.cwd(), 'logs')); + +const composeUp = async (args: string[]) => { + const { stdout, stderr } = await execAsync(`docker compose ${args.join(' ')}`); + + logger.info('stdout', stdout); + logger.info('stderr', stderr); + + return { stdout, stderr }; +}; + +/** + * Helpers to execute docker compose commands + * @param {string} appId - App name + * @param {string} command - Command to execute + */ +export const compose = async (appId: string, command: string) => { + const { arch, rootFolderHost, appsRepoId, storagePath } = getEnv(); + const appDataDirPath = path.join(storagePath, 'app-data', appId); + const appDirPath = path.join(rootFolderHost, 'apps', appId); + + const args: string[] = [`--env-file ${path.join(appDataDirPath, 'app.env')}`]; + + // User custom env file + const userEnvFile = path.join(rootFolderHost, 'user-config', appId, 'app.env'); + if (await pathExists(userEnvFile)) { + args.push(`--env-file ${userEnvFile}`); + } + + args.push(`--project-name ${appId}`); + + let composeFile = path.join(appDirPath, 'docker-compose.yml'); + if (arch === 'arm64' && (await pathExists(path.join(appDirPath, 'docker-compose.arm64.yml')))) { + composeFile = path.join(appDirPath, 'docker-compose.arm64.yml'); + } + args.push(`-f ${composeFile}`); + + const commonComposeFile = path.join(rootFolderHost, 'repos', appsRepoId, 'apps', 'docker-compose.common.yml'); + args.push(`-f ${commonComposeFile}`); + + // User defined overrides + const userComposeFile = path.join(rootFolderHost, 'user-config', appId, 'docker-compose.yml'); + if (await pathExists(userComposeFile)) { + args.push(`--file ${userComposeFile}`); + } + + args.push(command); + + return composeUp(args); +}; diff --git a/packages/cli/src/utils/docker-helpers/index.ts b/packages/cli/src/utils/docker-helpers/index.ts new file mode 100644 index 00000000..7ce2d161 --- /dev/null +++ b/packages/cli/src/utils/docker-helpers/index.ts @@ -0,0 +1 @@ +export * from './docker-helpers'; diff --git a/packages/cli/src/utils/environment/environment.ts b/packages/cli/src/utils/environment/environment.ts new file mode 100644 index 00000000..75414efa --- /dev/null +++ b/packages/cli/src/utils/environment/environment.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import dotenv from 'dotenv'; + +if (process.env.NODE_ENV === 'development') { + dotenv.config({ path: '.env.dev' }); +} else { + dotenv.config(); +} + +const environmentSchema = z + .object({ + STORAGE_PATH: z.string(), + ROOT_FOLDER_HOST: z.string(), + APPS_REPO_ID: z.string(), + ARCHITECTURE: z.enum(['arm64', 'amd64']), + INTERNAL_IP: z.string().ip().or(z.literal('localhost')), + TIPI_VERSION: z.string(), + }) + .transform((env) => { + const { STORAGE_PATH, ARCHITECTURE, ROOT_FOLDER_HOST, APPS_REPO_ID, INTERNAL_IP, TIPI_VERSION, ...rest } = env; + + return { + storagePath: STORAGE_PATH, + rootFolderHost: ROOT_FOLDER_HOST, + appsRepoId: APPS_REPO_ID, + arch: ARCHITECTURE, + tipiVersion: TIPI_VERSION, + internalIp: INTERNAL_IP, + ...rest, + }; + }); + +export type Environment = z.infer; + +export const getEnv = () => environmentSchema.parse(process.env); diff --git a/packages/cli/src/utils/fs-helpers/fs-helpers.ts b/packages/cli/src/utils/fs-helpers/fs-helpers.ts new file mode 100644 index 00000000..da42389e --- /dev/null +++ b/packages/cli/src/utils/fs-helpers/fs-helpers.ts @@ -0,0 +1,8 @@ +import fs from 'fs'; + +export const pathExists = async (path: string): Promise => { + return fs.promises + .access(path) + .then(() => true) + .catch(() => false); +}; diff --git a/packages/cli/src/utils/fs-helpers/index.ts b/packages/cli/src/utils/fs-helpers/index.ts new file mode 100644 index 00000000..a564d8be --- /dev/null +++ b/packages/cli/src/utils/fs-helpers/index.ts @@ -0,0 +1 @@ +export * from './fs-helpers'; diff --git a/packages/cli/src/utils/logger/terminal-spinner.ts b/packages/cli/src/utils/logger/terminal-spinner.ts new file mode 100644 index 00000000..1fb9f85d --- /dev/null +++ b/packages/cli/src/utils/logger/terminal-spinner.ts @@ -0,0 +1,55 @@ +import logUpdate from 'log-update'; +import chalk from 'chalk'; +import { dots } from 'cli-spinners'; + +export class TerminalSpinner { + message: string; + + frame = 0; + + interval: NodeJS.Timer | null = null; + + start() { + this.interval = setInterval(() => { + // eslint-disable-next-line no-plusplus + this.frame = ++this.frame % dots.frames.length; + logUpdate(`${dots.frames[this.frame]} ${this.message}`); + }, dots.interval); + } + + constructor(message: string) { + this.message = message; + } + + setMessage(message: string) { + this.message = message; + } + + done(message?: string) { + if (this.interval) { + clearInterval(this.interval); + } + + if (message) { + logUpdate(chalk.green('✓'), message); + } else { + logUpdate.clear(); + } + + logUpdate.done(); + } + + fail(message?: string) { + if (this.interval) { + clearInterval(this.interval); + } + + if (message) { + logUpdate(chalk.red('✗'), message); + } else { + logUpdate.clear(); + } + + logUpdate.done(); + } +} diff --git a/packages/cli/tests/apps.factory.ts b/packages/cli/tests/apps.factory.ts new file mode 100644 index 00000000..d0ec42b3 --- /dev/null +++ b/packages/cli/tests/apps.factory.ts @@ -0,0 +1,40 @@ +import { faker } from '@faker-js/faker'; +import fs from 'fs'; +import { APP_CATEGORIES, AppInfo, appInfoSchema } from '@runtipi/shared'; +import { getEnv } from '@/utils/environment/environment'; + +export const createAppConfig = (props?: Partial, isInstalled = true) => { + const { rootFolderHost, storagePath } = getEnv(); + + const appInfo = appInfoSchema.parse({ + id: faker.string.alphanumeric(32), + available: true, + port: faker.number.int({ min: 30, max: 65535 }), + name: faker.string.alphanumeric(32), + description: faker.string.alphanumeric(32), + tipi_version: 1, + short_desc: faker.string.alphanumeric(32), + author: faker.string.alphanumeric(32), + source: faker.internet.url(), + categories: [APP_CATEGORIES.AUTOMATION], + ...props, + }); + + const mockFiles: Record = {}; + mockFiles[`${rootFolderHost}/.env`] = 'TEST=test'; + mockFiles[`${rootFolderHost}/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo)); + mockFiles[`${rootFolderHost}/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose'; + mockFiles[`${rootFolderHost}/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc'; + + if (isInstalled) { + mockFiles[`${rootFolderHost}/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo)); + mockFiles[`${rootFolderHost}/apps/${appInfo.id}/docker-compose.yml`] = 'compose'; + mockFiles[`${rootFolderHost}/apps/${appInfo.id}/metadata/description.md`] = 'md desc'; + mockFiles[`${storagePath}/app-data/${appInfo.id}/data/test.txt`] = 'data'; + } + + // @ts-expect-error - custom mock method + fs.__applyMockFiles(mockFiles); + + return appInfo; +}; diff --git a/packages/cli/tests/mocks/fs.ts b/packages/cli/tests/mocks/fs.ts new file mode 100644 index 00000000..6f2fa5b8 --- /dev/null +++ b/packages/cli/tests/mocks/fs.ts @@ -0,0 +1,41 @@ +import { fs, vol } from 'memfs'; + +const copyFolderRecursiveSync = (src: string, dest: string) => { + const exists = vol.existsSync(src); + const stats = vol.statSync(src); + const isDirectory = exists && stats.isDirectory(); + if (isDirectory) { + vol.mkdirSync(dest, { recursive: true }); + vol.readdirSync(src).forEach((childItemName) => { + copyFolderRecursiveSync(`${src}/${childItemName}`, `${dest}/${childItemName}`); + }); + } else { + vol.copyFileSync(src, dest); + } +}; + +export const fsMock = { + default: { + ...fs, + promises: { + ...fs.promises, + cp: copyFolderRecursiveSync, + }, + copySync: (src: string, dest: string) => { + copyFolderRecursiveSync(src, dest); + }, + __resetAllMocks: () => { + vol.reset(); + }, + __applyMockFiles: (newMockFiles: Record) => { + // Create folder tree + vol.fromJSON(newMockFiles, 'utf8'); + }, + __createMockFiles: (newMockFiles: Record) => { + vol.reset(); + // Create folder tree + vol.fromJSON(newMockFiles, 'utf8'); + }, + __printVol: () => console.log(vol.toTree()), + }, +}; diff --git a/packages/cli/tests/vite.setup.ts b/packages/cli/tests/vite.setup.ts new file mode 100644 index 00000000..7d66473f --- /dev/null +++ b/packages/cli/tests/vite.setup.ts @@ -0,0 +1,34 @@ +import fs from 'fs'; +import path from 'path'; +import { vi, beforeEach } from 'vitest'; +import { getEnv } from '@/utils/environment/environment'; + +vi.mock('@runtipi/shared', async (importOriginal) => { + const mod = (await importOriginal()) as object; + + return { + ...mod, + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + error: vi.fn(), + }), + }; +}); + +vi.mock('fs', async () => { + const { fsMock } = await import('@/tests/mocks/fs'); + return { + ...fsMock, + }; +}); + +beforeEach(async () => { + // @ts-expect-error - custom mock method + fs.__resetAllMocks(); + + const { rootFolderHost, appsRepoId } = getEnv(); + + await fs.promises.mkdir(path.join(rootFolderHost, 'state'), { recursive: true }); + await fs.promises.writeFile(path.join(rootFolderHost, 'state', 'seed'), 'seed'); + await fs.promises.mkdir(path.join(rootFolderHost, 'repos', appsRepoId, 'apps'), { recursive: true }); +}); diff --git a/scripts/app.sh b/scripts/app.sh deleted file mode 100755 index bf6e7e8e..00000000 --- a/scripts/app.sh +++ /dev/null @@ -1,279 +0,0 @@ -#!/usr/bin/env bash -echo "Starting app script" - -source "${BASH_SOURCE%/*}/common.sh" - -set -euo pipefail - -ensure_pwd - -ROOT_FOLDER="${PWD}" -STATE_FOLDER="${ROOT_FOLDER}/state" -ENV_FILE="${ROOT_FOLDER}/.env" - -# Root folder in host system -ROOT_FOLDER_HOST=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep ROOT_FOLDER_HOST | cut -d '=' -f2) -REPO_ID=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep APPS_REPO_ID | cut -d '=' -f2) -STORAGE_PATH=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep STORAGE_PATH | cut -d '=' -f2) - -write_log "Running app script: ROOT_FOLDER=${ROOT_FOLDER}, ROOT_FOLDER_HOST=${ROOT_FOLDER_HOST}, REPO_ID=${REPO_ID}, STORAGE_PATH=${STORAGE_PATH}" - -if [ -z ${1+x} ]; then - command="" -else - command="$1" -fi - -if [ -z ${2+x} ]; then - exit 1 -else - app="$2" - - app_dir="${ROOT_FOLDER}/apps/${app}" - - if [[ ! -d "${app_dir}" ]]; then - # copy from repo - echo "Copying app from repo" - mkdir -p "${app_dir}" - cp -r "${ROOT_FOLDER}/repos/${REPO_ID}/apps/${app}"/* "${app_dir}" - fi - - app_data_dir="${STORAGE_PATH}/app-data/${app}" - - if [[ -z "${app}" ]] || [[ ! -d "${app_dir}" ]]; then - echo "Error: \"${app}\" is not a valid app" - exit 1 - fi -fi - - -# Function below has been modified from Umbrel -# Required Notice: Copyright -# Umbrel (https://umbrel.com) -compose() { - local app="${1}" - shift - - arch=$(uname -m) - local architecture="${arch}" - - if [[ "$architecture" == "aarch64" ]]; then - architecture="arm64" - fi - - # App data folder - local app_compose_file="${app_dir}/docker-compose.yml" - - # Pick arm architecture if running on arm and if the app has a docker-compose.arm.yml file - if [[ "$architecture" == "arm"* ]] && [[ -f "${app_dir}/docker-compose.arm.yml" ]]; then - app_compose_file="${app_dir}/docker-compose.arm.yml" - fi - - # Pick arm architecture if running on arm and if the app has a docker-compose.arm64.yml file - if [[ "$architecture" == "arm64" ]] && [[ -f "${app_dir}/docker-compose.arm64.yml" ]]; then - app_compose_file="${app_dir}/docker-compose.arm64.yml" - fi - - local common_compose_file="${ROOT_FOLDER}/repos/${REPO_ID}/apps/docker-compose.common.yml" - - local user_compose_file="${ROOT_FOLDER}/user-config/${app}/docker-compose.yml" - local user_compose_args= - if [[ -f ${user_compose_file} ]]; then - user_compose_args="--file ${user_compose_file}" - fi - - local user_env_file="${ROOT_FOLDER}/user-config/${app}/app.env" - local user_env_args= - if [[ -f ${user_env_file} ]]; then - user_env_args="--env-file ${user_env_file}" - fi - - # Vars to use in compose file - export APP_DATA_DIR="${STORAGE_PATH}/app-data/${app}" - export ROOT_FOLDER_HOST="${ROOT_FOLDER_HOST}" - - write_log "Running docker compose -f ${app_compose_file} -f ${common_compose_file} ${*}" - write_log "APP_DATA_DIR=${APP_DATA_DIR}" - write_log "ROOT_FOLDER_HOST=${ROOT_FOLDER_HOST}" - - docker compose \ - --env-file "${app_data_dir}/app.env" \ - ${user_env_args} \ - --project-name "${app}" \ - --file "${app_compose_file}" \ - --file "${common_compose_file}" \ - ${user_compose_args} \ - "${@}" -} - -function ensure_permissions() { - local app="${1}" - - # if app_data_dir/data does not exist, create it - if [[ ! -d "${app_data_dir}/data" ]]; then - mkdir -p "${app_data_dir}/data" - fi - - # Check if app requires special uid and gid - if [[ -f "${app_dir}/config.json" ]]; then - uid=$(get_json_field "${app_dir}/config.json" uid) - gid=$(get_json_field "${app_dir}/config.json" gid) - - write_log "App requires uid=${uid} and gid=${gid}" - - if [[ "$uid" != "null" ]] && [[ "$gid" != "null" ]]; then - write_log "Setting uid and gid to ${uid}:${gid}" - if ! chown -R "${uid}:${gid}" "${app_data_dir}/data"; then - write_log "Failed to set uid and gid to ${uid}:${gid}" - fi - fi - fi - - # Remove all .gitkeep files from app data dir - find "${app_data_dir}" -name ".gitkeep" -exec rm -f {} \; - - chmod -R a+rwx "${app_data_dir}" -} - -function install_app() { - local app="${1}" - - # Write to file script.log - write_log "Installing app ${app}..." - - if ! compose "${app}" pull; then - write_log "Failed to pull app ${app}" - exit 1 - fi - - ensure_permissions "${app}" - - if ! compose "${app}" up -d; then - write_log "Failed to start app ${app}" - exit 1 - fi - - exit 0 -} - -function start_app() { - local app="${1}" - - write_log "Starting app ${app}..." - - ensure_permissions "${app}" - - # Pull images - if ! compose "${app}" pull; then - write_log "Failed to pull app ${app}" - fi - - if ! compose "${app}" up --detach --force-recreate --remove-orphans; then - write_log "Failed to start app ${app}" - exit 1 - fi - exit 0 -} - -function uninstall_app() { - local app="${1}" - - write_log "Removing images for app ${app}..." - - if ! compose "${app}" down --rmi all --remove-orphans; then - # just stop it if we can't remove the images - if ! compose "${app}" rm --force --stop; then - write_log "Failed to uninstall app ${app}" - exit 1 - fi - fi - - write_log "Deleting app data for app ${app}..." - if [[ -d "${app_data_dir}" ]]; then - rm -rf "${app_data_dir}" - fi - - if [[ -d "${app_dir}" ]]; then - rm -rf "${app_dir}" - fi - - write_log "Successfully uninstalled app ${app}" - exit -} - -function update_app() { - local app="${1}" - - if ! compose "${app}" up --detach --force-recreate --remove-orphans; then - write_log "Failed to update app ${app}" - fi - - if ! compose "${app}" down --rmi all --remove-orphans; then - # just stop it if we can't remove the images - if ! compose "${app}" rm --force --stop; then - write_log "Failed to update app ${app}" - exit 1 - fi - fi - - # Remove app - if [[ -d "${app_dir}" ]]; then - rm -rf "${app_dir}" - fi - - # Copy app from repo - cp -r "${ROOT_FOLDER}/repos/${REPO_ID}/apps/${app}" "${app_dir}" - - ensure_permissions "${app}" - - compose "${app}" pull - exit 0 -} - -function stop_app() { - local app="${1}" - - write_log "Stopping app ${app}..." - - if ! compose "${app}" rm --force --stop; then - write_log "Failed to stop app ${app}" - exit 1 - fi - - exit 0 -} - -# Install new app -if [[ "$command" = "install" ]]; then - install_app "${app}" -fi - -# Removes images and destroys all data for an app -if [[ "$command" = "uninstall" ]]; then - uninstall_app "${app}" -fi - -# Update an app -if [[ "$command" = "update" ]]; then - update_app "${app}" -fi - -# Stops an installed app -if [[ "$command" = "stop" ]]; then - stop_app "${app}" -fi - -# Starts an installed app -if [[ "$command" = "start" ]]; then - start_app "${app}" -fi - -if [[ "$command" = "clean" ]]; then - # Remove all stopped containers and unused images - write_log "Cleaning up..." - docker system prune --all --force - - exit 0 -fi - -exit 1 diff --git a/scripts/common.sh b/scripts/common.sh deleted file mode 100644 index f5446e4c..00000000 --- a/scripts/common.sh +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env bash - -ROOT_FOLDER="${PWD}" -STATE_FOLDER="${ROOT_FOLDER}/state" - -# Get field from json file -function get_json_field() { - local json_file="$1" - local field="$2" - - jq -r ".${field}" "${json_file}" -} - -function write_log() { - local message="$1" - local log_file="${PWD}/logs/script.log" - - echo "$(date) - ${message}" >>"${log_file}" -} - -# Function below is taken from Umbrel -# Required Notice: Copyright -# Umbrel (https://umbrel.com) -function derive_entropy() { - SEED_FILE="${STATE_FOLDER}/seed" - identifier="${1}" - tipi_seed=$(cat "${SEED_FILE}") || true - - if [[ -z "$tipi_seed" ]] || [[ -z "$identifier" ]]; then - echo >&2 "Seed file not found. exiting..." - exit 1 - fi - - printf "%s" "${identifier}" | openssl dgst -sha256 -hmac "${tipi_seed}" | sed 's/^.* //' -} - -function ensure_pwd() { - if [[ $(basename "$(pwd)") != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then - echo "Please run this script from the runtipi directory" - exit 1 - fi -} - -function ensure_root() { - if [[ $UID != 0 ]]; then - echo "Tipi must be started as root" - echo "Please re-run this script as" - echo " sudo ./scripts/start" - exit 1 - fi -} - -function ensure_linux() { - # Check we are on linux - if [[ "$(uname)" != "Linux" ]]; then - echo "Tipi only works on Linux" - exit 1 - fi -} - -function clean_logs() { - # Clean logs folder - local logs_folder="${ROOT_FOLDER}/logs" - - # Create the folder if it doesn't exist - if [[ ! -d "${logs_folder}" ]]; then - mkdir "${logs_folder}" - fi - - if [ "$(find "${logs_folder}" -maxdepth 1 -type f | wc -l)" -gt 0 ]; then - echo "Cleaning logs folder..." - - local files=($(ls -d "${logs_folder}"/* | xargs -n 1 basename | sed 's/\///g')) - - for file in "${files[@]}"; do - echo "Removing ${file}" - rm -rf "${ROOT_FOLDER}/logs/${file}" - done - fi -} - -function kill_watcher() { - local watcher_pid="$(ps aux | grep "scripts/watcher" | grep -v grep | awk '{print $2}')" - - # kill it if it's running - if [[ -n $watcher_pid ]]; then - # If multiline kill each pid - if [[ $watcher_pid == *" "* ]]; then - for pid in $watcher_pid; do - # shellcheck disable=SC2086 - kill -9 $pid - done - else - # shellcheck disable=SC2086 - kill -9 $watcher_pid - fi - fi - # pkill -f "watcher.sh" -} - -function generateTLSCert() { - local domain="$1" - - # If the certificate already exists for this domain, don't generate it again - if [[ -f "traefik/tls/$domain.txt" ]] && [[ -f "traefik/tls/cert.pem" ]] && [[ -f "traefik/tls/key.pem" ]]; then - return - fi - - rm -rf "traefik/tls/$domain.txt" - rm -rf "traefik/tls/cert.pem" - rm -rf "traefik/tls/key.pem" - - echo "Generating TLS certificate..." - - if ! openssl req -x509 -newkey rsa:4096 -keyout traefik/tls/key.pem -out traefik/tls/cert.pem -days 365 -subj "/O=runtipi.io/OU=IT/CN=*.${domain}/emailAddress=webmaster@${domain}" -addext "subjectAltName = DNS:*.${domain},DNS:${domain}" -nodes; then - echo "Failed to generate TLS certificate" - else - echo "TLS certificate generated" - # Create a file to indicate that the certificate has been generated for this domain - touch "traefik/tls/$domain.txt" - fi -} - - -function generate_env_file() { - echo "Generating .env file..." - env_variables=$1 - - json_file=$(mktemp) - echo "$env_variables" > "$json_file" - - local default_tz="Etc\/UTC" - local tz="$(timedatectl | grep "Time zone" | awk '{print $3}' | sed 's/\//\\\//g')" - if [[ -z "$tz" ]]; then - tz="$default_tz" - fi - - local architecture="$(uname -m | tr '[:upper:]' '[:lower:]')" - - if [[ "$architecture" == "aarch64" ]] || [[ "$architecture" == "armv8"* ]]; then - architecture="arm64" - 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" != "amd64" ]]; then - echo "Architecture ${architecture} not supported if you think this is a mistake, please open an issue on GitHub." - exit 1 - fi - - local dns_ip=$(get_json_field "$json_file" dns_ip) - local internal_ip=$(get_json_field "$json_file" internal_ip) - local jwt_secret=$(get_json_field "$json_file" jwt_secret) - local tipi_version=$(get_json_field "$json_file" tipi_version) - local nginx_port=$(get_json_field "$json_file" nginx_port) - local nginx_port_ssl=$(get_json_field "$json_file" nginx_port_ssl) - local repo_id=$(get_json_field "$json_file" repo_id) - local domain=$(get_json_field "$json_file" domain) - local postgres_password=$(get_json_field "$json_file" postgres_password) - local postgres_username=$(get_json_field "$json_file" postgres_username) - local postgres_dbname=$(get_json_field "$json_file" postgres_dbname) - local postgres_host=$(get_json_field "$json_file" postgres_host) - local postgres_port=$(get_json_field "$json_file" postgres_port) - local redis_host=$(get_json_field "$json_file" redis_host) - local demo_mode=$(get_json_field "$json_file" demo_mode) - local docker_tag=$(get_json_field "$json_file" docker_tag) - local local_domain=$(get_json_field "$json_file" local_domain) - local root_folder=$(get_json_field "$json_file" root_folder | sed 's/\//\\\//g') - local apps_repository=$(get_json_field "$json_file" apps_repository | sed 's/\//\\\//g') - local storage_path=$(get_json_field "$json_file" storage_path | sed 's/\//\\\//g') - - env_file=$(mktemp) - [[ -f "${ROOT_FOLDER}/.env" ]] && rm -f "${ROOT_FOLDER}/.env" - [[ -f "$ROOT_FOLDER/templates/env-sample" ]] && cp "$ROOT_FOLDER/templates/env-sample" "$env_file" - - - if [[ -f "${STATE_FOLDER}/settings.json" ]]; then - # If dnsIp is set in settings.json, use it - if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" dnsIp)" != "null" ]]; then - dns_ip=$(get_json_field "${STATE_FOLDER}/settings.json" dnsIp) - fi - - # If domain is set in settings.json, use it - if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" domain)" != "null" ]]; then - domain=$(get_json_field "${STATE_FOLDER}/settings.json" domain) - fi - - # If appsRepoUrl is set in settings.json, use it - if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoUrl)" != "null" ]]; then - apps_repository_temp=$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoUrl) - apps_repository="$(echo "${apps_repository_temp}" | sed 's/\//\\\//g')" - repo_id="$("${ROOT_FOLDER}"/scripts/git.sh get_hash "${apps_repository_temp}")" - fi - - # If port is set in settings.json, use it - if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" port)" != "null" ]]; then - nginx_port=$(get_json_field "${STATE_FOLDER}/settings.json" port) - fi - - # If sslPort is set in settings.json, use it - if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" sslPort)" != "null" ]]; then - nginx_port_ssl=$(get_json_field "${STATE_FOLDER}/settings.json" sslPort) - fi - - # If listenIp is set in settings.json, use it - if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)" != "null" ]]; then - internal_ip=$(get_json_field "${STATE_FOLDER}/settings.json" listenIp) - fi - - # If demoMode is set in settings.json, use it - if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" demoMode)" == "true" ]]; then - demo_mode="true" - fi - - # If storagePath is set in settings.json, use it - storage_path_settings=$(get_json_field "${STATE_FOLDER}/settings.json" storagePath) - if [[ "${storage_path_settings}" != "null" && "${storage_path_settings}" != "" ]]; then - storage_path_temp="${storage_path_settings}" - storage_path="$(echo "${storage_path_temp}" | sed 's/\//\\\//g')" - fi - - if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" localDomain)" != "null" ]]; then - local_domain=$(get_json_field "${STATE_FOLDER}/settings.json" localDomain) - fi - fi - - echo "Using domain ${domain} and port ${nginx_port}" - # If port is not 80 and domain is not example.com or tipi.localhost, we exit - if [[ "${nginx_port}" != "80" ]] && [[ "${domain}" != "example.com" ]] && [[ "${domain}" != "tipi.localhost" ]]; then - echo "Using a custom domain with a custom port is not supported" - exit 1 - fi - - os=$(uname) - sed_args=(-i) - # If os is macos, use gnu sed - if [[ "$os" == "Darwin" ]]; then - echo "Using gnu sed" - sed_args=(-i '') - fi - - # Function below is modified from Umbrel - # Required Notice: Copyright - # Umbrel (https://umbrel.com) - for template in ${env_file}; do - sed "${sed_args[@]}" "s//${dns_ip}/g" "${template}" - sed "${sed_args[@]}" "s//${internal_ip}/g" "${template}" - sed "${sed_args[@]}" "s//${tz}/g" "${template}" - sed "${sed_args[@]}" "s//${jwt_secret}/g" "${template}" - sed "${sed_args[@]}" "s//${root_folder}/g" "${template}" - sed "${sed_args[@]}" "s//${tipi_version}/g" "${template}" - sed "${sed_args[@]}" "s//${architecture}/g" "${template}" - sed "${sed_args[@]}" "s//${nginx_port}/g" "${template}" - sed "${sed_args[@]}" "s//${nginx_port_ssl}/g" "${template}" - sed "${sed_args[@]}" "s//${repo_id}/g" "${template}" - sed "${sed_args[@]}" "s//${apps_repository}/g" "${template}" - sed "${sed_args[@]}" "s//${domain}/g" "${template}" - sed "${sed_args[@]}" "s//${storage_path}/g" "${template}" - sed "${sed_args[@]}" "s//${postgres_password}/g" "${template}" - sed "${sed_args[@]}" "s//${postgres_username}/g" "${template}" - sed "${sed_args[@]}" "s//${postgres_dbname}/g" "${template}" - sed "${sed_args[@]}" "s//${postgres_port}/g" "${template}" - sed "${sed_args[@]}" "s//${postgres_host}/g" "${template}" - sed "${sed_args[@]}" "s//${redis_host}/g" "${template}" - sed "${sed_args[@]}" "s//${demo_mode}/g" "${template}" - sed "${sed_args[@]}" "s//${docker_tag}/g" "${template}" - sed "${sed_args[@]}" "s//${local_domain}/g" "${template}" - done - - generateTLSCert "$local_domain" - - mv -f "$env_file" "$ROOT_FOLDER/.env" - chmod a+rwx "$ROOT_FOLDER/.env" -} diff --git a/scripts/configure.sh b/scripts/configure.sh deleted file mode 100755 index 6c4a5caf..00000000 --- a/scripts/configure.sh +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env bash - -OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"')" -SUB_OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID_LIKE=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"')" - -function install_generic() { - local dependency="${1}" - local os="${2}" - - if [[ "${os}" == "debian" ]]; then - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "${dependency}" - return 0 - elif [[ "${os}" == "ubuntu" || "${os}" == "pop" ]]; then - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "${dependency}" - return 0 - elif [[ "${os}" == "centos" ]]; then - sudo yum install -y --allowerasing "${dependency}" - return 0 - elif [[ "${os}" == "fedora" ]]; then - sudo dnf -y install "${dependency}" - return 0 - elif [[ "${os}" == "arch" ]]; then - if ! sudo pacman -Sy --noconfirm "${dependency}" ; then - if command -v yay > /dev/null 2>&1 ; then - sudo -u $SUDO_USER yay -Sy --noconfirm "${dependency}" - else - echo "Could not install \"${dependency}\", either using pacman or the yay AUR helper. Please try installing it manually." - return 1 - fi - fi - return 0 - else - return 1 - fi -} - -function install_docker() { - local os="${1}" - echo "Installing docker for os ${os}" - - if [[ "${os}" == "debian" ]]; then - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg lsb-release - sudo mkdir -p /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null - sudo DEBIAN_FRONTEND=noninteractive apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin - return 0 - elif [[ "${os}" == "ubuntu" || "${os}" == "pop" ]]; then - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg lsb-release - sudo mkdir -p /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null - sudo DEBIAN_FRONTEND=noninteractive apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin - return 0 - elif [[ "${os}" == "centos" ]]; then - sudo yum install -y yum-utils - sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo - sudo yum install -y --allowerasing docker-ce docker-ce-cli containerd.io docker-compose-plugin - sudo systemctl start docker - sudo systemctl enable docker - return 0 - elif [[ "${os}" == "fedora" ]]; then - sudo dnf -y install dnf-plugins-core - sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo - sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin - sudo systemctl start docker - sudo systemctl enable docker - return 0 - elif [[ "${os}" == "arch" ]]; then - sudo pacman -Sy --noconfirm docker docker-compose - sudo systemctl start docker.service - sudo systemctl enable docker.service - return 0 - else - return 1 - fi -} - -function update_docker() { - local os="${1}" - echo "Updating Docker for os ${os}" >/dev/tty - - if [[ "${os}" == "debian" ]]; then - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin - return 0 - elif [[ "${os}" == "ubuntu" || "${os}" == "pop" ]]; then - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin - return 0 - elif [[ "${os}" == "centos" ]]; then - sudo yum install -y --allowerasing docker-ce docker-ce-cli containerd.io docker-compose-plugin - return 0 - elif [[ "${os}" == "fedora" ]]; then - sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin - return 0 - elif [[ "${os}" == "arch" ]]; then - sudo pacman -Sy --noconfirm docker docker-compose - return 0 - else - return 1 - fi -} - -echo "Updating system" -sudo DEBIAN_FRONTEND=noninteractive apt-get update -y -echo "Upgrading system" -sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y - -if ! command -v docker >/dev/null; then - echo "Installing docker" - install_docker "${OS}" - docker_result=$? - - if [[ docker_result -eq 0 ]]; then - echo "Docker installed" - else - echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}" - install_docker "${SUB_OS}" - docker_sub_result=$? - - if [[ docker_sub_result -eq 0 ]]; then - echo "Docker installed" - else - echo "Your system ${SUB_OS} is not supported please install docker manually" - exit 1 - fi - fi -fi - -function check_dependency_and_install() { - local dependency="${1}" - - if ! command -v "${dependency}" >/dev/null; then - echo "Installing ${dependency}" - install_generic "${dependency}" "${OS}" - install_result=$? - - if [[ install_result -eq 0 ]]; then - echo "${dependency} installed" - else - echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}" - install_generic "${dependency}" "${SUB_OS}" - install_sub_result=$? - - if [[ install_sub_result -eq 0 ]]; then - echo "${dependency} installed" - else - echo "Your system ${SUB_OS} is not supported please install ${dependency} manually" - exit 1 - fi - fi - fi -} - -check_dependency_and_install "jq" -check_dependency_and_install "fswatch" -check_dependency_and_install "openssl" diff --git a/scripts/deploy/release-rc.sh b/scripts/deploy/release-rc.sh deleted file mode 100755 index 9af9d918..00000000 --- a/scripts/deploy/release-rc.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t meienberger/runtipi:rc-"$(npm run version --silent)" . --push diff --git a/scripts/git.sh b/scripts/git.sh deleted file mode 100755 index 1a34c8dc..00000000 --- a/scripts/git.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash - -source "${BASH_SOURCE%/*}/common.sh" - -ensure_pwd - -ROOT_FOLDER="${PWD}" - -# Get a static hash based on the repo url -function get_hash() { - url="${1}" - echo -n "${url}" | sha256sum | awk '{print $1}' -} - -if [ -z ${1+x} ]; then - command="" -else - command="$1" -fi - -# Clone a repo -if [[ "$command" = "clone" ]]; then - repo="$2" - repo_hash=$(get_hash "${repo}") - - write_log "Cloning ${repo} to ${ROOT_FOLDER}/repos/${repo_hash}" - repo_dir="${ROOT_FOLDER}/repos/${repo_hash}" - if [ -d "${repo_dir}" ]; then - write_log "Repo already exists" - exit 0 - fi - - write_log "Cloning ${repo} to ${repo_dir}" - - if ! git clone "${repo}" "${repo_dir}"; then - write_log "Failed to clone repo" - exit 1 - fi - - write_log "Done" - exit 0 -fi - -# Update a repo -if [[ "$command" = "update" ]]; then - repo="$2" - repo_hash=$(get_hash "${repo}") - repo_dir="${ROOT_FOLDER}/repos/${repo_hash}" - git config --global --add safe.directory "${repo_dir}" - if [ ! -d "${repo_dir}" ]; then - write_log "Repo does not exist" - exit 1 - fi - - write_log "Updating ${repo} in ${repo_hash}" - cd "${repo_dir}" || exit - - if ! git pull origin "$(git rev-parse --abbrev-ref HEAD)"; then - cd "${ROOT_FOLDER}" || exit - write_log "Failed to update repo" - exit 1 - fi - - cd "${ROOT_FOLDER}" || exit - write_log "Done" - exit 0 -fi - -if [[ "$command" = "get_hash" ]]; then - repo="$2" - get_hash "${repo}" - exit -fi diff --git a/scripts/install.sh b/scripts/install.sh index 61918f44..f263763b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -13,107 +13,147 @@ if [[ "$ARCHITECTURE" == "armv7"* ]] || [[ "$ARCHITECTURE" == "i686" ]] || [[ "$ fi +OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"')" +SUB_OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID_LIKE=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"')" + +function install_generic() { + local dependency="${1}" + local os="${2}" + + if [[ "${os}" == "debian" ]]; then + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "${dependency}" + return 0 + elif [[ "${os}" == "ubuntu" || "${os}" == "pop" ]]; then + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "${dependency}" + return 0 + elif [[ "${os}" == "centos" ]]; then + sudo yum install -y --allowerasing "${dependency}" + return 0 + elif [[ "${os}" == "fedora" ]]; then + sudo dnf -y install "${dependency}" + return 0 + elif [[ "${os}" == "arch" ]]; then + if ! sudo pacman -Sy --noconfirm "${dependency}" ; then + if command -v yay > /dev/null 2>&1 ; then + sudo -u $SUDO_USER yay -Sy --noconfirm "${dependency}" + else + echo "Could not install \"${dependency}\", either using pacman or the yay AUR helper. Please try installing it manually." + return 1 + fi + fi + return 0 + else + return 1 + fi +} + +function install_docker() { + local os="${1}" + echo "Installing docker for os ${os}" + + if [[ "${os}" == "debian" ]]; then + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg lsb-release + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null + sudo DEBIAN_FRONTEND=noninteractive apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + return 0 + elif [[ "${os}" == "ubuntu" || "${os}" == "pop" ]]; then + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg lsb-release + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null + sudo DEBIAN_FRONTEND=noninteractive apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + return 0 + elif [[ "${os}" == "centos" ]]; then + sudo yum install -y yum-utils + sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo + sudo yum install -y --allowerasing docker-ce docker-ce-cli containerd.io docker-compose-plugin + sudo systemctl start docker + sudo systemctl enable docker + return 0 + elif [[ "${os}" == "fedora" ]]; then + sudo dnf -y install dnf-plugins-core + sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo + sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin + sudo systemctl start docker + sudo systemctl enable docker + return 0 + elif [[ "${os}" == "arch" ]]; then + sudo pacman -Sy --noconfirm docker docker-compose + sudo systemctl start docker.service + sudo systemctl enable docker.service + return 0 + else + return 1 + fi +} + +echo "Updating system" +sudo DEBIAN_FRONTEND=noninteractive apt-get update -y +echo "Upgrading system" +sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y + +if ! command -v docker >/dev/null; then + echo "Installing docker" + install_docker "${OS}" + docker_result=$? + + if [[ docker_result -eq 0 ]]; then + echo "Docker installed" + else + echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}" + install_docker "${SUB_OS}" + docker_sub_result=$? + + if [[ docker_sub_result -eq 0 ]]; then + echo "Docker installed" + else + echo "Your system ${SUB_OS} is not supported please install docker manually" + exit 1 + fi + fi +fi + +function check_dependency_and_install() { + local dependency="${1}" + + if ! command -v "${dependency}" >/dev/null; then + echo "Installing ${dependency}" + install_generic "${dependency}" "${OS}" + install_result=$? + + if [[ install_result -eq 0 ]]; then + echo "${dependency} installed" + else + echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}" + install_generic "${dependency}" "${SUB_OS}" + install_sub_result=$? + + if [[ install_sub_result -eq 0 ]]; then + echo "${dependency} installed" + else + echo "Your system ${SUB_OS} is not supported please install ${dependency} manually" + exit 1 + fi + fi + fi +} + +# Example +# check_dependency_and_install "openssl" + LATEST_VERSION=$(curl -s https://api.github.com/repos/meienberger/runtipi/releases/latest | grep tag_name | cut -d '"' -f4) -### -------------------------------- -### CLI arguments -### -------------------------------- -UPDATE="false" -while [ -n "${1-}" ]; do - case "$1" in - --update) UPDATE="true" ;; - --) - shift # The double dash makes them parameters - break - ;; - *) echo "Option $1 not recognized" && exit 1 ;; - esac - shift -done - -if [[ "${UPDATE}" == "false" ]]; then - mkdir -p runtipi - cd runtipi || exit +LATEST_ASSET="runtipi-cli-linux-x64" +if [ "$ARCHITECTURE" == "arm64" ] || [ "$ARCHITECTURE" == "aarch64" ]; then + LATEST_ASSET="runtipi-cli-linux-arm64" fi -curl --location https://api.github.com/repos/meienberger/runtipi/tarball/"${LATEST_VERSION}" -o runtipi.tar.gz -mkdir runtipi-"${LATEST_VERSION}" -tar -xzf runtipi.tar.gz -C runtipi-"${LATEST_VERSION}" --strip-components=1 -rm runtipi.tar.gz +URL="https://github.com/meienberger/runtipi/releases/download/$LATEST_VERSION/$LATEST_ASSET" -# copy from downloaded /scripts/* -if [ -d "scripts" ]; then - rm -rf scripts -fi -mkdir scripts -cp -r runtipi-"${LATEST_VERSION}"/scripts/* ./scripts +curl --location "$URL" -o runtipi-cli -# copy from downloaded /templates/* -if [ -d "templates" ]; then - rm -rf templates -fi -mkdir templates -cp -r runtipi-"${LATEST_VERSION}"/templates/* ./templates - -# copy from downloaded /traefik/* -if [ -d "traefik" ]; then - mv traefik traefik_old -fi -mkdir traefik -cp -r runtipi-"${LATEST_VERSION}"/traefik/* ./traefik - -if [ -d "traefik_old" ] && [ -d "traefik_old/tls" ]; then - ## move old traefik TLS config to new traefik config - cp -r traefik_old/tls traefik - rm -rf traefik_old -fi - -# copy from downloaded /docker-compose.yml -if [ -f "docker-compose.yml" ]; then - rm -f docker-compose.yml -fi -cp -r runtipi-"${LATEST_VERSION}"/docker-compose.yml . - -# copy from downloaded /package.json -if [ -f "package.json" ]; then - rm -f package.json -fi -cp -r runtipi-"${LATEST_VERSION}"/package.json . - -mkdir -p apps -mkdir -p app-data -mkdir -p state -mkdir -p repos - -mkdir -p traefik/shared -mkdir -p traefik/tls - -mkdir -p media/torrents -mkdir -p media/torrents/watch -mkdir -p media/torrents/complete -mkdir -p media/torrents/incomplete - -mkdir -p media/usenet -mkdir -p media/usenet/watch -mkdir -p media/usenet/complete -mkdir -p media/usenet/incomplete - -mkdir -p media/downloads -mkdir -p media/downloads/watch -mkdir -p media/downloads/complete -mkdir -p media/downloads/incomplete - -mkdir -p media/data -mkdir -p media/data/books -mkdir -p media/data/comics -mkdir -p media/data/movies -mkdir -p media/data/music -mkdir -p media/data/tv -mkdir -p media/data/podcasts -mkdir -p media/data/images -mkdir -p media/data/roms - -## remove downloaded folder -rm -rf runtipi-"${LATEST_VERSION}" - -sudo ./scripts/start.sh +sudo ./runtipi-cli start diff --git a/scripts/reset-password.sh b/scripts/reset-password.sh deleted file mode 100755 index 64502cfb..00000000 --- a/scripts/reset-password.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -source "${BASH_SOURCE%/*}/common.sh" - -ensure_pwd - -ROOT_FOLDER="$(pwd)" -STATE_FOLDER="${ROOT_FOLDER}/state" - -# Create file request-password-change in state folder -touch "${STATE_FOLDER}/password-change-request" diff --git a/scripts/start-dev.sh b/scripts/start-dev.sh deleted file mode 100755 index 5d44ff42..00000000 --- a/scripts/start-dev.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -set -o errexit -set -o nounset -set -o pipefail -if [[ "${TRACE-0}" == "1" ]]; then - set -o xtrace -fi - -source "${BASH_SOURCE%/*}/common.sh" - -clean_logs - -### -------------------------------- -### General variables -### -------------------------------- -ROOT_FOLDER="${PWD}" -STATE_FOLDER="${ROOT_FOLDER}/state" - -### -------------------------------- -### Apps repository configuration -### -------------------------------- -apps_repository="https://github.com/meienberger/runtipi-appstore" - -env_variables_json=$(cat <"${ROOT_FOLDER}/state/system-info.json" -fi - -chmod -R a+rwx "${ROOT_FOLDER}/state/events" -chmod -R a+rwx "${ROOT_FOLDER}/state/system-info.json" -kill_watcher -"${ROOT_FOLDER}/scripts/watcher.sh" & - -### -------------------------------- -### env file generation -### -------------------------------- -generate_env_file "${env_variables_json}" - -### -------------------------------- -### Start the project -### -------------------------------- -docker compose -f docker-compose.dev.yml up --build diff --git a/scripts/start-e2e.sh b/scripts/start-e2e.sh deleted file mode 100755 index 73c823d5..00000000 --- a/scripts/start-e2e.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env bash -set -o errexit -set -o nounset -set -o pipefail -if [[ "${TRACE-0}" == "1" ]]; then - set -o xtrace -fi - -export DEBIAN_FRONTEND=noninteractive - -source "${BASH_SOURCE%/*}/common.sh" - -clean_logs - -### -------------------------------- -### General variables -### -------------------------------- -ROOT_FOLDER="${PWD}" -STATE_FOLDER="${ROOT_FOLDER}/state" -## Comes from first argument -DOCKER_TAG="${1}" -echo "Starting e2e tests with tag meienberger/runtipi:${DOCKER_TAG}" - -### -------------------------------- -### Pre-configuration -### -------------------------------- -sudo "${ROOT_FOLDER}/scripts/configure.sh" -mkdir -p "${ROOT_FOLDER}/state" -STATE_FOLDER="${ROOT_FOLDER}/state" - -mkdir -p traefik -mkdir -p traefik/shared -mkdir -p traefik/tls - -if [[ ! -f "${STATE_FOLDER}/seed" ]]; then - echo "Generating seed..." - mkdir -p "${STATE_FOLDER}" - touch "${STATE_FOLDER}/seed" - - if ! tr "${STATE_FOLDER}/seed"; then - echo "Created seed file..." - fi -fi - -### -------------------------------- -### Apps repository configuration -### -------------------------------- -apps_repository="https://github.com/meienberger/runtipi-appstore" - -env_variables_json=$(cat <"${ROOT_FOLDER}/state/system-info.json" -fi - -chmod -R a+rwx "${ROOT_FOLDER}/state/events" -chmod -R a+rwx "${ROOT_FOLDER}/state/system-info.json" -echo "kill previous watcher" -kill_watcher -echo "starting watcher" -nohup "${ROOT_FOLDER}/scripts/watcher.sh" > /dev/null 2>&1 & - -### -------------------------------- -### env file generation -### -------------------------------- -echo "Generating env file..." -generate_env_file "${env_variables_json}" - -### -------------------------------- -### Start the project -### -------------------------------- -echo "Starting docker-compose..." -docker compose -f docker-compose.e2e.yml up -d --build diff --git a/scripts/start.sh b/scripts/start.sh deleted file mode 100755 index 3ac1e0f6..00000000 --- a/scripts/start.sh +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env bash - -set -o errexit -set -o nounset -set -o pipefail -if [[ "${TRACE-0}" == "1" ]]; then - set -o xtrace -fi -source "${BASH_SOURCE%/*}/common.sh" - -ROOT_FOLDER="${PWD}" - -# Cleanup and ensure environment -ensure_linux -ensure_pwd -ensure_root -clean_logs - -### -------------------------------- -### Pre-configuration -### -------------------------------- -"${ROOT_FOLDER}/scripts/configure.sh" - -STATE_FOLDER="${ROOT_FOLDER}/state" -if [[ ! -f "${STATE_FOLDER}/seed" ]]; then - echo "Generating seed..." - if ! tr "${STATE_FOLDER}/seed"; then - echo "Created seed file..." - fi -fi - -### -------------------------------- -### General variables -### -------------------------------- -apps_repository="https://github.com/meienberger/runtipi-appstore" -INTERNAL_IP= - - -if [[ -f "${STATE_FOLDER}/settings.json" ]]; then - # If listenIp is set in settings.json, use it - if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)" != "null" ]]; then - INTERNAL_IP=$(get_json_field "${STATE_FOLDER}/settings.json" listenIp) - fi -fi - -if [[ -z "${INTERNAL_IP:-}" ]]; then - network_interface="$(ip route | grep default | awk '{print $5}' | uniq)" - network_interface_count=$(echo "$network_interface" | wc -l) - - if [[ "$network_interface_count" -eq 0 ]]; then - echo "No network interface found!" - exit 1 - elif [[ "$network_interface_count" -gt 1 ]]; then - echo "Found multiple network interfaces. Please select one of the following interfaces:" - echo "$network_interface" - while true; do - read -rp "> " USER_NETWORK_INTERFACE - if echo "$network_interface" | grep -x "$USER_NETWORK_INTERFACE"; then - network_interface="$USER_NETWORK_INTERFACE" - break - else - echo "Please select one of the interfaces above. (CTRL+C to abort)" - fi - done - fi - - INTERNAL_IP="$(ip addr show "${network_interface}" | grep "inet " | awk '{print $2}' | cut -d/ -f1)" - internal_ip_count=$(echo "$INTERNAL_IP" | wc -l) - - if [[ "$internal_ip_count" -eq 0 ]]; then - echo "No IP address found for network interface ${network_interface}! Set the IP address manually with --listen-ip or with the listenIp field in settings.json." - exit 1 - elif [[ "$internal_ip_count" -gt 1 ]]; then - echo "Found multiple IP addresses for network interface ${network_interface}. Please select one of the following IP addresses:" - echo "$INTERNAL_IP" - while true; do - read -rp "> " USER_INTERNAL_IP - if echo "$INTERNAL_IP" | grep -x "$USER_INTERNAL_IP"; then - INTERNAL_IP="$USER_INTERNAL_IP" - break - else - echo "Please select one of the IP addresses above. (CTRL+C to abort)" - fi - done - fi -fi - -env_variables_json=$(cat <._ \X-=/^ - ( / `-._//^` - `Y-.____(__} - | {__) - ()` -EOF - -echo "" -echo "Visit http://${INTERNAL_IP}/ to view the dashboard" -echo "" diff --git a/scripts/stop.sh b/scripts/stop.sh deleted file mode 100755 index d6a62261..00000000 --- a/scripts/stop.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -source "${BASH_SOURCE%/*}/common.sh" - -ensure_pwd -ensure_root - -ROOT_FOLDER="${PWD}" -ENV_FILE="${ROOT_FOLDER}/.env" -STORAGE_PATH=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep STORAGE_PATH | cut -d '=' -f2) - -# Stop all installed apps if there are any -apps_folder="${ROOT_FOLDER}/apps" -if [ "$(find "${apps_folder}" -maxdepth 1 -type d | wc -l)" -gt 1 ]; then - apps_names=($(ls -d "${apps_folder}"/*/ | xargs -n 1 basename | sed 's/\///g')) - - for app_name in "${apps_names[@]}"; do - # if folder ${ROOT_FOLDER}/app-data/app_name exists, then stop app - if [[ -d "${STORAGE_PATH}/app-data/${app_name}" ]]; then - echo "Stopping ${app_name}" - "${ROOT_FOLDER}/scripts/app.sh" stop "$app_name" - fi - done -else - echo "No app installed that can be stopped." -fi - -kill_watcher -echo "Stopping tipi..." -echo -docker compose down --remove-orphans --rmi local diff --git a/scripts/system-info.sh b/scripts/system-info.sh deleted file mode 100755 index 9c00bd0b..00000000 --- a/scripts/system-info.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -set -e # Exit immediately if a command exits with a non-zero status. - -ROOT_FOLDER="${PWD}" -STATE_FOLDER="${ROOT_FOLDER}/state" - -# if not on linux exit -if [[ "$(uname)" != "Linux" ]]; then - echo '{"cpu": { "load": 0 },"memory": { "available": 0, "total": 0, "used": 0 },"disk": { "available": 0, "total": 0, "used": 0 }}' >"${STATE_FOLDER}/system-info.json" - exit 0 -fi - -ROOT_FOLDER="$(pwd)" -STATE_FOLDER="${ROOT_FOLDER}/state" - -# Available disk space -TOTAL_DISK_SPACE_BYTES=$(df -P -B 1 / | tail -n 1 | awk '{print $2}') -AVAILABLE_DISK_SPACE_BYTES=$(df -P -B 1 / | tail -n 1 | awk '{print $4}') -USED_DISK_SPACE_BYTES=$((TOTAL_DISK_SPACE_BYTES - AVAILABLE_DISK_SPACE_BYTES)) - -# CPU info -CPU_LOAD_PERCENTAGE=$(top -bn1 | grep "Cpu(s)" | sed "s/.*, *\([0-9.]*\)%* id.*/\1/" | awk '{print 100 - $1}') - -# Memory info -MEM_TOTAL_BYTES=$(($(grep "${TEMP_JSON_FILE}" - -# Write to state file -cat "${TEMP_JSON_FILE}" >"${STATE_FOLDER}/system-info.json" -chmod -R a+rwx "${ROOT_FOLDER}/state/system-info.json" diff --git a/scripts/system.sh b/scripts/system.sh deleted file mode 100755 index 2da24dce..00000000 --- a/scripts/system.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash - -source "${BASH_SOURCE%/*}/common.sh" -ensure_pwd - -ROOT_FOLDER="${PWD}" - -if [ -z ${1+x} ]; then - command="" -else - command="$1" -fi - -function update() { - write_log "Updating Tipi..." - - local current_version=$(get_json_field "${ROOT_FOLDER}/package.json" version) - # check latest version - local latest=$(curl -s https://api.github.com/repos/meienberger/runtipi/releases/latest | grep tag_name | cut -d '"' -f4) - - scripts/stop.sh - - # backup current version to backups/${current_version}/ - local timestamp=$(date +%s) - local backup_folder="${ROOT_FOLDER}/backups/${current_version}-${timestamp}" - - mkdir -p "${backup_folder}" - cp -r "${ROOT_FOLDER}/scripts" "${backup_folder}" - cp -r "${ROOT_FOLDER}/templates" "${backup_folder}" - cp -r "${ROOT_FOLDER}/traefik" "${backup_folder}" - cp -r "${ROOT_FOLDER}/package.json" "${backup_folder}" - cp -r "${ROOT_FOLDER}/docker-compose.yml" "${backup_folder}" - - # download install.sh from latest release to install-${latest_version}.sh - curl -L https://raw.githubusercontent.com/meienberger/runtipi/master/scripts/install.sh >install-"${latest}".sh - - chmod +x ./install-"${latest}".sh - # run install-${latest_version}.sh - ./install-"${latest}".sh --update - - # remove install-${latest_version}.sh - rm install-"${latest}".sh - rm -rf runtipi-"${latest}" - rm -rf runtipi.tar.gz - - exit 0 -} - -function restart() { - write_log "Restarting Tipi..." - - scripts/stop.sh - scripts/start.sh - - exit -} - -# Restart Tipi -if [[ "$command" = "restart" ]]; then - restart -fi - -# Update Tipi -if [[ "$command" = "update" ]]; then - update -fi diff --git a/scripts/unsafe-cleanup.sh b/scripts/unsafe-cleanup.sh index 9adee7b4..67c7d36a 100755 --- a/scripts/unsafe-cleanup.sh +++ b/scripts/unsafe-cleanup.sh @@ -26,4 +26,4 @@ rm -rf "${ROOT_FOLDER}/data/postgres" mkdir -p "${ROOT_FOLDER}/app-data" cd "$ROOT_FOLDER" || echo "" -"${ROOT_FOLDER}/scripts/start.sh" +sudo ./runtipi-cli start diff --git a/scripts/watcher.sh b/scripts/watcher.sh deleted file mode 100755 index ce93056c..00000000 --- a/scripts/watcher.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash - -source "${BASH_SOURCE%/*}/common.sh" - -ROOT_FOLDER="${PWD}" -WATCH_FILE="${ROOT_FOLDER}/state/events" - -function clean_events() { - # Create the file if it doesn't exist - if [[ ! -f "${WATCH_FILE}" ]]; then - touch "${WATCH_FILE}" - fi - - echo "" >"$WATCH_FILE" - - chmod -R a+rwx "${ROOT_FOLDER}/state/events" -} - -function set_status() { - local id=$1 - local status=$2 - - write_log "Setting status for ${id} to ${status}" - - # Update the status of the event - if [[ "$(uname)" != "Linux" ]]; then - sed -i '' "s/${id} [a-z]*/${id} ${status}/g" "${WATCH_FILE}" - else - sed -i "s/${id}.*$/$(echo "${id} ${status}" | sed 's/\//\\\//g')/" "$WATCH_FILE" - fi -} - -function run_command() { - local command_path="${1}" - local id=$2 - shift 2 - - set_status "$id" "running" - - $command_path "$@" >>"${ROOT_FOLDER}/logs/${id}.log" 2>&1 - - local result=$? - - if [[ $result -eq 0 ]]; then - set_status "$id" "success" - else - set_status "$id" "error" - fi -} - -function select_command() { - # Example command: - # clone_repo id waiting "args" - - local command=$(echo "$1" | cut -d ' ' -f 1) - local id=$(echo "$1" | cut -d ' ' -f 2) - local status=$(echo "$1" | cut -d ' ' -f 3) - local args=$(echo "$1" | cut -d ' ' -f 4-) - - if [[ "$status" != "waiting" ]]; then - return 0 - fi - - write_log "Executing command ${command}" - - if [ -z "$command" ]; then - return 0 - fi - - if [ "$command" = "clone_repo" ]; then - run_command "${ROOT_FOLDER}/scripts/git.sh" "$id" "clone" "$args" - return 0 - fi - - if [ "$command" = "update_repo" ]; then - run_command "${ROOT_FOLDER}/scripts/git.sh" "$id" "update" "$args" - return 0 - fi - - if [ "$command" = "app" ]; then - local arg1=$(echo "$args" | cut -d ' ' -f 1) - local arg2=$(echo "$args" | cut -d ' ' -f 2) - - # Args example: start filebrowser - run_command "${ROOT_FOLDER}/scripts/app.sh" "$id" "$arg1" "$arg2" - return 0 - fi - - if [ "$command" = "system_info" ]; then - run_command "${ROOT_FOLDER}/scripts/system-info.sh" "$id" - return 0 - fi - - if [ "$command" = "update" ]; then - run_command "${ROOT_FOLDER}/scripts/system.sh" "$id" "update" - return 0 - fi - - if [ "$command" = "restart" ]; then - run_command "${ROOT_FOLDER}/scripts/system.sh" "$id" "restart" - return 0 - fi - - return 0 -} - -write_log "Listening for events in ${WATCH_FILE}..." -clean_events -# Listen in for changes in the WATCH_FILE -fswatch -0 "${WATCH_FILE}" | while read -d ""; do - # Read the command from the last line of the file - command=$(tail -n 1 "${WATCH_FILE}") - status=$(echo "$command" | cut -d ' ' -f 3) - - if [ -z "$command" ] || [ "$status" != "waiting" ]; then - continue - else - select_command "$command" - fi -done diff --git a/src/server/services/apps/apps.types.ts b/src/server/services/apps/apps.types.ts deleted file mode 100644 index e5947b84..00000000 --- a/src/server/services/apps/apps.types.ts +++ /dev/null @@ -1,32 +0,0 @@ -export const APP_CATEGORIES = { - NETWORK: 'network', - MEDIA: 'media', - DEVELOPMENT: 'development', - AUTOMATION: 'automation', - SOCIAL: 'social', - UTILITIES: 'utilities', - PHOTOGRAPHY: 'photography', - SECURITY: 'security', - FEATURED: 'featured', - BOOKS: 'books', - DATA: 'data', - MUSIC: 'music', - FINANCE: 'finance', - GAMING: 'gaming', - AI: 'ai', -} as const; - -export type AppCategory = (typeof APP_CATEGORIES)[keyof typeof APP_CATEGORIES]; - -export const FIELD_TYPES = { - TEXT: 'text', - PASSWORD: 'password', - EMAIL: 'email', - NUMBER: 'number', - FQDN: 'fqdn', - IP: 'ip', - FQDNIP: 'fqdnip', - URL: 'url', - RANDOM: 'random', - BOOLEAN: 'boolean', -} as const; diff --git a/templates/env-sample b/templates/env-sample deleted file mode 100644 index 68c638a2..00000000 --- a/templates/env-sample +++ /dev/null @@ -1,25 +0,0 @@ -# Only edit this file if you know what you are doing! -# It will be overwritten on update. - -APPS_REPO_ID= -APPS_REPO_URL= -TZ= -INTERNAL_IP= -DNS_IP= -ARCHITECTURE= -TIPI_VERSION= -JWT_SECRET= -ROOT_FOLDER_HOST= -NGINX_PORT= -NGINX_PORT_SSL= -DOMAIN= -STORAGE_PATH= -POSTGRES_HOST= -POSTGRES_DBNAME= -POSTGRES_USERNAME= -POSTGRES_PASSWORD= -POSTGRES_PORT= -REDIS_HOST= -DEMO_MODE= -LOCAL_DOMAIN= -DOCKER_TAG= diff --git a/traefik/README.md b/traefik/README.md deleted file mode 100644 index 729b95b7..00000000 --- a/traefik/README.md +++ /dev/null @@ -1,3 +0,0 @@ -mkcert -install - -mkcert -cert-file ./local-cert.pem -key-file ./local-key.pem "docker.localhost" "*.docker.localhost" "tipi.local" "*.tipi.local" \ No newline at end of file