commit
7aabf0de7b
89 changed files with 2795 additions and 1297 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -3,7 +3,7 @@ on:
|
|||
push:
|
||||
|
||||
env:
|
||||
ROOT_FOLDER: /test
|
||||
ROOT_FOLDER: /runtipi
|
||||
JWT_SECRET: "secret"
|
||||
ROOT_FOLDER_HOST: /tipi
|
||||
APPS_REPO_ID: repo-id
|
||||
|
|
29
.gitignore
vendored
29
.gitignore
vendored
|
@ -1,11 +1,15 @@
|
|||
*.swo
|
||||
*.swp
|
||||
|
||||
.DS_Store
|
||||
|
||||
logs
|
||||
.pnpm-debug.log
|
||||
.env*
|
||||
github.secrets
|
||||
node_modules/
|
||||
app-data/*
|
||||
data/postgres
|
||||
traefik/ssl/*
|
||||
!traefik/ssl/.gitkeep
|
||||
!app-data/.gitkeep
|
||||
repos/*
|
||||
!repos/.gitkeep
|
||||
|
@ -14,25 +18,6 @@ apps/*
|
|||
traefik/shared
|
||||
media
|
||||
|
||||
scripts/pacapt
|
||||
|
||||
state/*
|
||||
!state/.gitkeep
|
||||
|
||||
media/data/movies/*
|
||||
media/data/tv/*
|
||||
media/data/books/spoken/*
|
||||
media/data/books/ebooks/*
|
||||
!media/data/movies/.gitkeep
|
||||
!media/data/tv/.gitkeep
|
||||
!media/data/books/metadata.db
|
||||
!media/data/books/ebooks/.gitkeep
|
||||
!media/data/books/spoken/.gitkeep
|
||||
|
||||
media/torrents/complete/*
|
||||
!media/torrents/complete/.gitkeep
|
||||
media/torrents/incomplete/*
|
||||
!media/torrents/incomplete/.gitkeep
|
||||
media/torrents/watch/*
|
||||
!media/torrents/watch/.gitkeep
|
||||
packages/dashboard/package-lock.json
|
||||
media
|
||||
|
|
12
Dockerfile
12
Dockerfile
|
@ -23,21 +23,23 @@ FROM alpine:3.16.0 as app
|
|||
|
||||
WORKDIR /
|
||||
|
||||
# Install dependencies
|
||||
RUN apk --no-cache add docker-compose nodejs npm bash g++ make git
|
||||
# # Install dependencies
|
||||
RUN apk --no-cache add nodejs npm
|
||||
RUN apk --no-cache add g++
|
||||
RUN apk --no-cache add make
|
||||
RUN apk --no-cache add python3
|
||||
|
||||
RUN npm install node-gyp -g
|
||||
|
||||
WORKDIR /api
|
||||
COPY ./packages/system-api/package*.json /api/
|
||||
RUN npm install --production
|
||||
RUN npm install --omit=dev
|
||||
|
||||
WORKDIR /dashboard
|
||||
COPY ./packages/dashboard/package*.json /dashboard/
|
||||
RUN npm install --production
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY --from=build /api/dist /api/dist
|
||||
COPY ./packages/system-api /api
|
||||
|
||||
COPY --from=build /dashboard/.next /dashboard/.next
|
||||
COPY ./packages/dashboard /dashboard
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
FROM alpine:3.16.0 as app
|
||||
FROM node:18-alpine3.16
|
||||
|
||||
WORKDIR /
|
||||
|
||||
# Install docker
|
||||
RUN apk --no-cache add docker-compose nodejs npm bash g++ make git
|
||||
|
||||
RUN apk --no-cache add g++ make
|
||||
RUN npm install node-gyp -g
|
||||
|
||||
WORKDIR /api
|
||||
|
|
17
README.md
17
README.md
|
@ -94,6 +94,21 @@ To stop Tipi, run the stop script.
|
|||
sudo ./scripts/stop.sh
|
||||
```
|
||||
|
||||
### Custom settings
|
||||
You can change the default settings by creating a `settings.json` file. The file should be located in the `state` directory. This file will make your changes persist across restarts. Example file:
|
||||
|
||||
```json
|
||||
{
|
||||
"dnsIp": "9.9.9.9", // DNS IP address
|
||||
"domain": "mydomain.com", // Domain name to link to the dashboard
|
||||
"port": 7000, // Change default http port 80
|
||||
"sslPort": 7001, // Change default ssl port 443
|
||||
"listenIp": "192.168.1.1", // Change default listen ip (advanced)
|
||||
"storagePath": "/mnt/usb", // Change default storage path of app data
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Linking a domain to your dashboard
|
||||
If you want to link a domain to your dashboard, you can do so by providing the `--domain` option in the start script.
|
||||
|
||||
|
@ -101,6 +116,8 @@ If you want to link a domain to your dashboard, you can do so by providing the `
|
|||
sudo ./scripts/start.sh --domain mydomain.com
|
||||
```
|
||||
|
||||
You can also specify it in the `settings.json` file as shown in the previous section.
|
||||
|
||||
A Let's Encrypt certificate will be generated and installed automatically. Make sure to have ports 80 and 443 open on your firewall and that your domain has an **A** record pointing to your server IP.
|
||||
|
||||
## ❤️ Contributing
|
||||
|
|
|
@ -51,16 +51,18 @@ services:
|
|||
ports:
|
||||
- 3001:3001
|
||||
volumes:
|
||||
## Docker sock
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ${PWD}:/tipi
|
||||
- ${PWD}/repos:/runtipi/repos:ro
|
||||
- ${PWD}/apps:/runtipi/apps
|
||||
- ${PWD}/state:/runtipi/state
|
||||
- ${PWD}/packages/system-api/src:/api/src
|
||||
- ${STORAGE_PATH}:/app/storage
|
||||
- ${PWD}/logs:/app/logs
|
||||
- ${PWD}/.env.dev:/runtipi/.env
|
||||
# - /api/node_modules
|
||||
environment:
|
||||
INTERNAL_IP: ${INTERNAL_IP}
|
||||
TIPI_VERSION: ${TIPI_VERSION}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ROOT_FOLDER_HOST: ${ROOT_FOLDER_HOST}
|
||||
NGINX_PORT: ${NGINX_PORT}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_USERNAME: tipi
|
||||
|
@ -88,6 +90,9 @@ services:
|
|||
dockerfile: Dockerfile.dev
|
||||
command: /bin/sh -c "cd /dashboard && npm run dev"
|
||||
container_name: dashboard
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
ports:
|
||||
- 3000:3000
|
||||
networks:
|
||||
|
|
|
@ -44,14 +44,16 @@ services:
|
|||
tipi-db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
## Docker sock
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ${PWD}:/tipi
|
||||
- ${PWD}/repos:/runtipi/repos:ro
|
||||
- ${PWD}/apps:/runtipi/apps
|
||||
- ${PWD}/state:/runtipi/state
|
||||
- ${PWD}/logs:/app/logs
|
||||
- ${STORAGE_PATH}:/app/storage
|
||||
- ${PWD}/.env:/runtipi/.env:ro
|
||||
environment:
|
||||
INTERNAL_IP: ${INTERNAL_IP}
|
||||
TIPI_VERSION: ${TIPI_VERSION}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ROOT_FOLDER_HOST: ${ROOT_FOLDER_HOST}
|
||||
NGINX_PORT: ${NGINX_PORT}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_USERNAME: tipi
|
||||
|
@ -61,8 +63,6 @@ services:
|
|||
APPS_REPO_ID: ${APPS_REPO_ID}
|
||||
APPS_REPO_URL: ${APPS_REPO_URL}
|
||||
DOMAIN: ${DOMAIN}
|
||||
dns:
|
||||
- ${DNS_IP}
|
||||
networks:
|
||||
- tipi_main_network
|
||||
labels:
|
||||
|
@ -89,6 +89,9 @@ services:
|
|||
container_name: dashboard
|
||||
networks:
|
||||
- tipi_main_network
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
environment:
|
||||
INTERNAL_IP: ${INTERNAL_IP}
|
||||
NODE_ENV: production
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
version: "3.9"
|
||||
version: "3.7"
|
||||
|
||||
services:
|
||||
reverse-proxy:
|
||||
|
@ -44,14 +44,16 @@ services:
|
|||
tipi-db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
## Docker sock
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ${PWD}:/tipi
|
||||
- ${PWD}/repos:/runtipi/repos:ro
|
||||
- ${PWD}/apps:/runtipi/apps
|
||||
- ${PWD}/state:/runtipi/state
|
||||
- ${PWD}/logs:/app/logs
|
||||
- ${STORAGE_PATH}:/app/storage
|
||||
- ${PWD}/.env:/runtipi/.env:ro
|
||||
environment:
|
||||
INTERNAL_IP: ${INTERNAL_IP}
|
||||
TIPI_VERSION: ${TIPI_VERSION}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ROOT_FOLDER_HOST: ${ROOT_FOLDER_HOST}
|
||||
NGINX_PORT: ${NGINX_PORT}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_USERNAME: tipi
|
||||
|
@ -61,8 +63,6 @@ services:
|
|||
APPS_REPO_ID: ${APPS_REPO_ID}
|
||||
APPS_REPO_URL: ${APPS_REPO_URL}
|
||||
DOMAIN: ${DOMAIN}
|
||||
dns:
|
||||
- ${DNS_IP}
|
||||
networks:
|
||||
- tipi_main_network
|
||||
labels:
|
||||
|
@ -90,6 +90,9 @@ services:
|
|||
container_name: dashboard
|
||||
networks:
|
||||
- tipi_main_network
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
environment:
|
||||
INTERNAL_IP: ${INTERNAL_IP}
|
||||
NODE_ENV: production
|
||||
|
|
0
media/data/music/.gitkeep
Normal file
0
media/data/music/.gitkeep
Normal file
|
@ -1,17 +1,18 @@
|
|||
{
|
||||
"name": "runtipi",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"description": "A homeserver for everyone",
|
||||
"scripts": {
|
||||
"prepare": "husky install",
|
||||
"commit": "git-cz",
|
||||
"act:test-install": "act --container-architecture linux/amd64 -j test-install",
|
||||
"act:docker": "act --container-architecture linux/amd64 --secret-file github.secrets -j build-images",
|
||||
"start:dev": "docker-compose -f docker-compose.dev.yml --env-file .env.dev up --build",
|
||||
"start:dev": "sudo ./scripts/start-dev.sh",
|
||||
"start:rc": "docker-compose -f docker-compose.rc.yml --env-file .env up --build",
|
||||
"start:prod": "docker-compose --env-file .env up --build",
|
||||
"start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres",
|
||||
"version": "echo $npm_package_version"
|
||||
"version": "echo $npm_package_version",
|
||||
"release:rc": "./scripts/deploy/release-rc.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^17.0.3",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const { INTERNAL_IP, DOMAIN } = process.env;
|
||||
const { INTERNAL_IP, DOMAIN, NGINX_PORT } = process.env;
|
||||
|
||||
const nextConfig = {
|
||||
webpackDevMiddleware: (config) => {
|
||||
|
@ -10,10 +10,6 @@ const nextConfig = {
|
|||
return config;
|
||||
},
|
||||
reactStrictMode: true,
|
||||
env: {
|
||||
INTERNAL_IP: INTERNAL_IP,
|
||||
NEXT_PUBLIC_DOMAIN: DOMAIN,
|
||||
},
|
||||
basePath: '/dashboard',
|
||||
};
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "dashboard",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "jest --colors",
|
||||
|
@ -17,14 +17,11 @@
|
|||
"@emotion/react": "^11",
|
||||
"@emotion/styled": "^11",
|
||||
"@fontsource/open-sans": "^4.5.8",
|
||||
"axios": "^0.26.1",
|
||||
"clsx": "^1.1.1",
|
||||
"final-form": "^4.20.6",
|
||||
"framer-motion": "^6",
|
||||
"graphql": "^15.8.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"immer": "^9.0.12",
|
||||
"js-cookie": "^3.0.1",
|
||||
"next": "12.1.6",
|
||||
"react": "18.1.0",
|
||||
"react-dom": "18.1.0",
|
||||
|
@ -36,7 +33,6 @@
|
|||
"remark-gfm": "^3.0.1",
|
||||
"remark-mdx": "^2.1.1",
|
||||
"swr": "^1.3.0",
|
||||
"systeminformation": "^5.11.9",
|
||||
"tslib": "^2.4.0",
|
||||
"validator": "^13.7.0",
|
||||
"zustand": "^3.7.2"
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { useSytemStore } from '../../state/systemStore';
|
||||
import { useSystemStore } from '../../state/systemStore';
|
||||
|
||||
const AppLogo: React.FC<{ id: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
|
||||
const { baseUrl } = useSytemStore();
|
||||
const { baseUrl } = useSystemStore();
|
||||
const logoUrl = `${baseUrl}/apps/${id}/metadata/logo.jpg`;
|
||||
|
||||
return (
|
||||
|
|
|
@ -6,7 +6,6 @@ import { FiChevronRight } from 'react-icons/fi';
|
|||
import Header from './Header';
|
||||
import Menu from './SideMenu';
|
||||
import MenuDrawer from './MenuDrawer';
|
||||
// import UpdateBanner from './UpdateBanner';
|
||||
|
||||
interface IProps {
|
||||
loading?: boolean;
|
||||
|
|
|
@ -10,6 +10,7 @@ import { useRouter } from 'next/router';
|
|||
import { IconType } from 'react-icons';
|
||||
import { useLogoutMutation, useVersionQuery } from '../../generated/graphql';
|
||||
import { getUrl } from '../../core/helpers/url-helpers';
|
||||
import { BsHeart } from 'react-icons/bs';
|
||||
|
||||
const SideMenu: React.FC = () => {
|
||||
const router = useRouter();
|
||||
|
@ -57,6 +58,12 @@ const SideMenu: React.FC = () => {
|
|||
<Flex flex="1" />
|
||||
<List>
|
||||
<div className="mx-3">
|
||||
<a href="https://github.com/meienberger/runtipi?sponsor=1" target="_blank" rel="noreferrer">
|
||||
<ListItem className="cursor-pointer hover:font-bold flex items-center mb-4">
|
||||
<BsHeart size={20} className="mr-3" />
|
||||
<p className="flex-1 mb-1 text-md">Donate</p>
|
||||
</ListItem>
|
||||
</a>
|
||||
<ListItem onClick={() => logout()} className="cursor-pointer hover:font-bold flex items-center mb-5">
|
||||
<FiLogOut size={20} className="mr-3" />
|
||||
<p className="flex-1">Log out</p>
|
||||
|
@ -68,6 +75,7 @@ const SideMenu: React.FC = () => {
|
|||
</ListItem>
|
||||
</div>
|
||||
</List>
|
||||
|
||||
<div className="pb-1 text-center text-sm text-gray-400 mt-5">Tipi version {Package.version}</div>
|
||||
{!isLatest && (
|
||||
<Badge className="self-center mt-1" colorScheme="green">
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
import { Flex, Spinner, Text } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
const RestartingScreen = () => {
|
||||
return (
|
||||
<Flex height="100vh" direction="column" alignItems="center" justifyContent="center">
|
||||
<Text fontSize="2xl">Your system is restarting...</Text>
|
||||
<Text color="gray.500">Please do not refresh this page</Text>
|
||||
<Spinner size="lg" className="mt-5" />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default RestartingScreen;
|
|
@ -0,0 +1,50 @@
|
|||
import { SlideFade } from '@chakra-ui/react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { SystemStatus, useSystemStore } from '../../state/systemStore';
|
||||
import RestartingScreen from './RestartingScreen';
|
||||
import UpdatingScreen from './UpdatingScreen';
|
||||
|
||||
interface IProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
const StatusWrapper: React.FC<IProps> = ({ children }) => {
|
||||
const [s, setS] = useState<SystemStatus>(SystemStatus.RUNNING);
|
||||
const { baseUrl } = useSystemStore();
|
||||
const { data } = useSWR(`${baseUrl}/status`, fetcher, { refreshInterval: 1000 });
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.status === SystemStatus.RUNNING) {
|
||||
setS(SystemStatus.RUNNING);
|
||||
}
|
||||
if (data?.status === SystemStatus.RESTARTING) {
|
||||
setS(SystemStatus.RESTARTING);
|
||||
}
|
||||
if (data?.status === SystemStatus.UPDATING) {
|
||||
setS(SystemStatus.UPDATING);
|
||||
}
|
||||
}, [data?.status]);
|
||||
|
||||
if (s === SystemStatus.RESTARTING) {
|
||||
return (
|
||||
<SlideFade in>
|
||||
<RestartingScreen />
|
||||
</SlideFade>
|
||||
);
|
||||
}
|
||||
|
||||
if (s === SystemStatus.UPDATING) {
|
||||
return (
|
||||
<SlideFade in>
|
||||
<UpdatingScreen />
|
||||
</SlideFade>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default StatusWrapper;
|
|
@ -0,0 +1,14 @@
|
|||
import { Text, Flex, Spinner } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
const UpdatingScreen = () => {
|
||||
return (
|
||||
<Flex height="100vh" direction="column" alignItems="center" justifyContent="center">
|
||||
<Text fontSize="2xl">Your system is updating...</Text>
|
||||
<Text color="gray.500">Please do not refresh this page</Text>
|
||||
<Spinner size="lg" className="mt-5" />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdatingScreen;
|
|
@ -1,34 +0,0 @@
|
|||
import axios, { Method } from 'axios';
|
||||
import { useSytemStore } from '../state/systemStore';
|
||||
|
||||
interface IFetchParams {
|
||||
endpoint: string;
|
||||
method?: Method;
|
||||
params?: JSON;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
|
||||
const { endpoint, method = 'GET', params, data } = fetchParams;
|
||||
|
||||
const { getState } = useSytemStore;
|
||||
const BASE_URL = getState().baseUrl;
|
||||
|
||||
const response = await axios.request<T & { error?: string }>({
|
||||
method,
|
||||
params,
|
||||
data,
|
||||
url: `${BASE_URL}${endpoint}`,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
if (response.data.error) {
|
||||
throw new Error(response.data.error);
|
||||
}
|
||||
|
||||
if (response.data) return response.data;
|
||||
|
||||
throw new Error(`Network request error. status : ${response.status}`);
|
||||
};
|
||||
|
||||
export default { fetch: api };
|
|
@ -1,11 +0,0 @@
|
|||
import { BareFetcher } from 'swr';
|
||||
import axios from 'axios';
|
||||
import { useSytemStore } from '../state/systemStore';
|
||||
|
||||
const fetcher: BareFetcher<any> = (url: string) => {
|
||||
const { baseUrl } = useSytemStore.getState();
|
||||
|
||||
return axios.get(url, { baseURL: baseUrl, withCredentials: true }).then((res) => res.data);
|
||||
};
|
||||
|
||||
export default fetcher;
|
|
@ -1,10 +1,5 @@
|
|||
export const getUrl = (url: string) => {
|
||||
const domain = process.env.NEXT_PUBLIC_DOMAIN;
|
||||
let prefix = '';
|
||||
|
||||
if (domain !== 'tipi.localhost') {
|
||||
prefix = 'dashboard';
|
||||
}
|
||||
let prefix = 'dashboard';
|
||||
|
||||
return `/${prefix}/${url}`;
|
||||
};
|
||||
|
|
|
@ -23,7 +23,7 @@ export type App = {
|
|||
__typename?: 'App';
|
||||
config: Scalars['JSONObject'];
|
||||
createdAt: Scalars['DateTime'];
|
||||
domain: Scalars['String'];
|
||||
domain?: Maybe<Scalars['String']>;
|
||||
exposed: Scalars['Boolean'];
|
||||
id: Scalars['String'];
|
||||
info?: Maybe<AppInfo>;
|
||||
|
@ -137,9 +137,11 @@ export type Mutation = {
|
|||
login: UserResponse;
|
||||
logout: Scalars['Boolean'];
|
||||
register: UserResponse;
|
||||
restart: Scalars['Boolean'];
|
||||
startApp: App;
|
||||
stopApp: App;
|
||||
uninstallApp: App;
|
||||
update: Scalars['Boolean'];
|
||||
updateApp: App;
|
||||
updateAppConfig: App;
|
||||
};
|
||||
|
@ -251,6 +253,10 @@ export type RegisterMutationVariables = Exact<{
|
|||
|
||||
export type RegisterMutation = { __typename?: 'Mutation'; register: { __typename?: 'UserResponse'; user?: { __typename?: 'User'; id: string } | null } };
|
||||
|
||||
export type RestartMutationVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type RestartMutation = { __typename?: 'Mutation'; restart: boolean };
|
||||
|
||||
export type StartAppMutationVariables = Exact<{
|
||||
id: Scalars['String'];
|
||||
}>;
|
||||
|
@ -269,6 +275,10 @@ export type UninstallAppMutationVariables = Exact<{
|
|||
|
||||
export type UninstallAppMutation = { __typename?: 'Mutation'; uninstallApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
|
||||
|
||||
export type UpdateMutationVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type UpdateMutation = { __typename?: 'Mutation'; update: boolean };
|
||||
|
||||
export type UpdateAppMutationVariables = Exact<{
|
||||
id: Scalars['String'];
|
||||
}>;
|
||||
|
@ -294,7 +304,7 @@ export type GetAppQuery = {
|
|||
config: any;
|
||||
version?: number | null;
|
||||
exposed: boolean;
|
||||
domain: string;
|
||||
domain?: string | null;
|
||||
updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
|
||||
info?: {
|
||||
__typename?: 'AppInfo';
|
||||
|
@ -523,6 +533,36 @@ export function useRegisterMutation(baseOptions?: Apollo.MutationHookOptions<Reg
|
|||
export type RegisterMutationHookResult = ReturnType<typeof useRegisterMutation>;
|
||||
export type RegisterMutationResult = Apollo.MutationResult<RegisterMutation>;
|
||||
export type RegisterMutationOptions = Apollo.BaseMutationOptions<RegisterMutation, RegisterMutationVariables>;
|
||||
export const RestartDocument = gql`
|
||||
mutation Restart {
|
||||
restart
|
||||
}
|
||||
`;
|
||||
export type RestartMutationFn = Apollo.MutationFunction<RestartMutation, RestartMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useRestartMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useRestartMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useRestartMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [restartMutation, { data, loading, error }] = useRestartMutation({
|
||||
* variables: {
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useRestartMutation(baseOptions?: Apollo.MutationHookOptions<RestartMutation, RestartMutationVariables>) {
|
||||
const options = { ...defaultOptions, ...baseOptions };
|
||||
return Apollo.useMutation<RestartMutation, RestartMutationVariables>(RestartDocument, options);
|
||||
}
|
||||
export type RestartMutationHookResult = ReturnType<typeof useRestartMutation>;
|
||||
export type RestartMutationResult = Apollo.MutationResult<RestartMutation>;
|
||||
export type RestartMutationOptions = Apollo.BaseMutationOptions<RestartMutation, RestartMutationVariables>;
|
||||
export const StartAppDocument = gql`
|
||||
mutation StartApp($id: String!) {
|
||||
startApp(id: $id) {
|
||||
|
@ -628,6 +668,36 @@ export function useUninstallAppMutation(baseOptions?: Apollo.MutationHookOptions
|
|||
export type UninstallAppMutationHookResult = ReturnType<typeof useUninstallAppMutation>;
|
||||
export type UninstallAppMutationResult = Apollo.MutationResult<UninstallAppMutation>;
|
||||
export type UninstallAppMutationOptions = Apollo.BaseMutationOptions<UninstallAppMutation, UninstallAppMutationVariables>;
|
||||
export const UpdateDocument = gql`
|
||||
mutation Update {
|
||||
update
|
||||
}
|
||||
`;
|
||||
export type UpdateMutationFn = Apollo.MutationFunction<UpdateMutation, UpdateMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useUpdateMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useUpdateMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useUpdateMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [updateMutation, { data, loading, error }] = useUpdateMutation({
|
||||
* variables: {
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useUpdateMutation(baseOptions?: Apollo.MutationHookOptions<UpdateMutation, UpdateMutationVariables>) {
|
||||
const options = { ...defaultOptions, ...baseOptions };
|
||||
return Apollo.useMutation<UpdateMutation, UpdateMutationVariables>(UpdateDocument, options);
|
||||
}
|
||||
export type UpdateMutationHookResult = ReturnType<typeof useUpdateMutation>;
|
||||
export type UpdateMutationResult = Apollo.MutationResult<UpdateMutation>;
|
||||
export type UpdateMutationOptions = Apollo.BaseMutationOptions<UpdateMutation, UpdateMutationVariables>;
|
||||
export const UpdateAppDocument = gql`
|
||||
mutation UpdateApp($id: String!) {
|
||||
updateApp(id: $id) {
|
||||
|
|
3
packages/dashboard/src/graphql/mutations/restart.graphql
Normal file
3
packages/dashboard/src/graphql/mutations/restart.graphql
Normal file
|
@ -0,0 +1,3 @@
|
|||
mutation Restart {
|
||||
restart
|
||||
}
|
3
packages/dashboard/src/graphql/mutations/update.graphql
Normal file
3
packages/dashboard/src/graphql/mutations/update.graphql
Normal file
|
@ -0,0 +1,3 @@
|
|||
mutation Update {
|
||||
update
|
||||
}
|
|
@ -1,9 +1,8 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { ApolloClient } from '@apollo/client';
|
||||
import axios from 'axios';
|
||||
import useSWR, { BareFetcher } from 'swr';
|
||||
import { createApolloClient } from '../core/apollo/client';
|
||||
import { useSytemStore } from '../state/systemStore';
|
||||
import { useSystemStore } from '../state/systemStore';
|
||||
import useSWR, { Fetcher } from 'swr';
|
||||
import { getUrl } from '../core/helpers/url-helpers';
|
||||
|
||||
interface IReturnProps {
|
||||
|
@ -11,13 +10,11 @@ interface IReturnProps {
|
|||
isLoadingComplete?: boolean;
|
||||
}
|
||||
|
||||
const fetcher: BareFetcher<any> = (url: string) => {
|
||||
return axios.get(getUrl(url)).then((res) => res.data);
|
||||
};
|
||||
const fetcher: Fetcher<{ ip: string; domain: string; port: string }, string> = (...args) => fetch(...args).then((res) => res.json());
|
||||
|
||||
export default function useCachedResources(): IReturnProps {
|
||||
const { data } = useSWR<{ ip: string; domain: string; port: string }>('api/ip', fetcher);
|
||||
const { baseUrl, setBaseUrl, setInternalIp, setDomain } = useSytemStore();
|
||||
const { data } = useSWR(getUrl('api/getenv'), fetcher);
|
||||
const { baseUrl, setBaseUrl, setInternalIp, setDomain } = useSystemStore();
|
||||
const [isLoadingComplete, setLoadingComplete] = useState(false);
|
||||
const [client, setClient] = useState<ApolloClient<unknown>>();
|
||||
|
||||
|
@ -28,7 +25,7 @@ export default function useCachedResources(): IReturnProps {
|
|||
setClient(restoredClient);
|
||||
} catch (error) {
|
||||
// We might want to provide this error information to an error reporting service
|
||||
console.warn(error);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoadingComplete(true);
|
||||
}
|
||||
|
@ -36,6 +33,7 @@ export default function useCachedResources(): IReturnProps {
|
|||
|
||||
useEffect(() => {
|
||||
const { ip, domain, port } = data || {};
|
||||
|
||||
if (ip && !baseUrl) {
|
||||
setInternalIp(ip);
|
||||
setDomain(domain);
|
||||
|
@ -50,7 +48,7 @@ export default function useCachedResources(): IReturnProps {
|
|||
setBaseUrl(`https://${domain}/api`);
|
||||
}
|
||||
}
|
||||
}, [baseUrl, setBaseUrl, data, setInternalIp, setDomain]);
|
||||
}, [baseUrl, setBaseUrl, setInternalIp, setDomain, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (baseUrl) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { SlideFade, Flex, Divider, useDisclosure, useToast } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import { FiExternalLink } from 'react-icons/fi';
|
||||
import { useSytemStore } from '../../../state/systemStore';
|
||||
import { useSystemStore } from '../../../state/systemStore';
|
||||
import AppActions from '../components/AppActions';
|
||||
import InstallModal from '../components/InstallModal';
|
||||
import StopModal from '../components/StopModal';
|
||||
|
@ -48,7 +48,7 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
|
|||
|
||||
const updateAvailable = Number(app?.updateInfo?.current || 0) < Number(app?.updateInfo?.latest);
|
||||
|
||||
const { internalIp } = useSytemStore();
|
||||
const { internalIp } = useSystemStore();
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
|
@ -207,7 +207,7 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
|
|||
app={info}
|
||||
config={app?.config}
|
||||
exposed={app?.exposed}
|
||||
domain={app?.domain}
|
||||
domain={app?.domain || ''}
|
||||
/>
|
||||
<UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={info} newVersion={newVersion} />
|
||||
</div>
|
||||
|
|
|
@ -8,12 +8,14 @@ import AuthWrapper from '../modules/Auth/containers/AuthWrapper';
|
|||
import { ApolloProvider } from '@apollo/client';
|
||||
import useCachedResources from '../hooks/useCachedRessources';
|
||||
import Head from 'next/head';
|
||||
import StatusWrapper from '../components/StatusScreens/StatusWrapper';
|
||||
import LoadingScreen from '../components/LoadingScreen';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
const { client } = useCachedResources();
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -22,9 +24,11 @@ function MyApp({ Component, pageProps }: AppProps) {
|
|||
<Head>
|
||||
<title>Tipi</title>
|
||||
</Head>
|
||||
<AuthWrapper>
|
||||
<Component {...pageProps} />
|
||||
</AuthWrapper>
|
||||
<StatusWrapper>
|
||||
<AuthWrapper>
|
||||
<Component {...pageProps} />
|
||||
</AuthWrapper>
|
||||
</StatusWrapper>
|
||||
</ChakraProvider>
|
||||
</ApolloProvider>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export default function ip(_: any, res: any) {
|
||||
export default function getEnv(_: any, res: any) {
|
||||
const { INTERNAL_IP } = process.env;
|
||||
const { NGINX_PORT } = process.env;
|
||||
const { DOMAIN } = process.env;
|
|
@ -1,13 +1,34 @@
|
|||
import type { NextPage } from 'next';
|
||||
import { Text } from '@chakra-ui/react';
|
||||
import { AlertDialog, AlertDialogBody, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, Button, Text, useDisclosure, useToast } from '@chakra-ui/react';
|
||||
import Layout from '../components/Layout';
|
||||
import { useVersionQuery } from '../generated/graphql';
|
||||
import { useLogoutMutation, useRestartMutation, useUpdateMutation, useVersionQuery } from '../generated/graphql';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
const Settings: NextPage = () => {
|
||||
const { data, loading } = useVersionQuery();
|
||||
const toast = useToast();
|
||||
const restartDisclosure = useDisclosure();
|
||||
const updateDisclosure = useDisclosure();
|
||||
const cancelRef = useRef<any>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { data } = useVersionQuery();
|
||||
|
||||
const [restart] = useRestartMutation();
|
||||
const [update] = useUpdateMutation();
|
||||
const [logout] = useLogoutMutation({ refetchQueries: ['Me'] });
|
||||
const isLatest = data?.version.latest === data?.version.current;
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
position: 'top',
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderUpdate = () => {
|
||||
if (isLatest) {
|
||||
return (
|
||||
|
@ -18,22 +39,84 @@ const Settings: NextPage = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Text fontSize="md">
|
||||
You are not using the latest version of Tipi. There is a new version ({data?.version.latest}) available. Visit{' '}
|
||||
<a className="text-blue-600" target="_blank" rel="noreferrer" href={`https://github.com/meienberger/runtipi/releases/v${data?.version.latest}`}>
|
||||
Github
|
||||
</a>{' '}
|
||||
for update instructions.
|
||||
</Text>
|
||||
<>
|
||||
<Text fontSize="md">New version available</Text>
|
||||
<Button onClick={updateDisclosure.onOpen} className="mr-2" colorScheme="green">
|
||||
Update to {data?.version.latest}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
restart();
|
||||
logout();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
update();
|
||||
logout();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout loading={!data?.version && loading}>
|
||||
<Text fontSize="3xl" className="font-bold">
|
||||
Settings
|
||||
</Text>
|
||||
{renderUpdate()}
|
||||
<Button onClick={restartDisclosure.onOpen} colorScheme="gray">
|
||||
Restart
|
||||
</Button>
|
||||
<AlertDialog isOpen={restartDisclosure.isOpen} leastDestructiveRef={cancelRef} onClose={restartDisclosure.onClose}>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
Restart Tipi
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogBody>Would you like to restart your Tipi server?</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Button colorScheme="gray" ref={cancelRef} onClick={restartDisclosure.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button colorScheme="red" isLoading={loading} onClick={handleRestart} ml={3}>
|
||||
Restart
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
<AlertDialog isOpen={updateDisclosure.isOpen} leastDestructiveRef={cancelRef} onClose={updateDisclosure.onClose}>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
Update Tipi
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogBody>Would you like to update Tipi to the latest version?</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Button colorScheme="gray" ref={cancelRef} onClick={updateDisclosure.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button colorScheme="green" isLoading={loading} onClick={handleUpdate} ml={3}>
|
||||
Update
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
import create from 'zustand';
|
||||
import Cookies from 'js-cookie';
|
||||
import api from '../core/api';
|
||||
import { IUser } from '../core/types';
|
||||
|
||||
type AppsStore = {
|
||||
user: IUser | null;
|
||||
configured: boolean;
|
||||
me: () => Promise<void>;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
fetchConfigured: () => Promise<void>;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AppsStore>((set) => ({
|
||||
user: null,
|
||||
configured: false,
|
||||
loading: false,
|
||||
me: async () => {
|
||||
try {
|
||||
set({ loading: true });
|
||||
const response = await api.fetch<{ user: IUser | null }>({ endpoint: '/auth/me' });
|
||||
|
||||
set({ user: response.user, loading: false });
|
||||
} catch (error) {
|
||||
set({ loading: false, user: null });
|
||||
}
|
||||
},
|
||||
login: async (email: string, password: string) => {
|
||||
set({ loading: true });
|
||||
|
||||
try {
|
||||
const response = await api.fetch<{ user: IUser }>({
|
||||
endpoint: '/auth/login',
|
||||
method: 'post',
|
||||
data: { email, password },
|
||||
});
|
||||
set({ user: response.user, loading: false });
|
||||
} catch (e) {
|
||||
set({ loading: false });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
logout: async () => {
|
||||
Cookies.remove('tipi_token');
|
||||
|
||||
set({ user: null, loading: false });
|
||||
},
|
||||
register: async (email: string, password: string) => {
|
||||
set({ loading: true });
|
||||
|
||||
try {
|
||||
const response = await api.fetch<{ user: IUser }>({
|
||||
endpoint: '/auth/register',
|
||||
method: 'post',
|
||||
data: { email, password },
|
||||
});
|
||||
set({ user: response.user, loading: false });
|
||||
} catch (e) {
|
||||
set({ loading: false });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
fetchConfigured: async () => {
|
||||
try {
|
||||
const response = await api.fetch<{ configured: boolean }>({ endpoint: '/auth/configured' });
|
||||
set({ configured: response.configured });
|
||||
} catch (e) {
|
||||
set({ configured: false });
|
||||
}
|
||||
},
|
||||
}));
|
|
@ -1,19 +1,29 @@
|
|||
import create from 'zustand';
|
||||
|
||||
export enum SystemStatus {
|
||||
RUNNING = 'RUNNING',
|
||||
RESTARTING = 'RESTARTING',
|
||||
UPDATING = 'UPDATING',
|
||||
}
|
||||
|
||||
type Store = {
|
||||
baseUrl: string;
|
||||
internalIp: string;
|
||||
domain: string;
|
||||
status: SystemStatus;
|
||||
setDomain: (domain?: string) => void;
|
||||
setBaseUrl: (url: string) => void;
|
||||
setInternalIp: (ip: string) => void;
|
||||
setStatus: (status: SystemStatus) => void;
|
||||
};
|
||||
|
||||
export const useSytemStore = create<Store>((set) => ({
|
||||
export const useSystemStore = create<Store>((set) => ({
|
||||
baseUrl: '',
|
||||
internalIp: '',
|
||||
domain: '',
|
||||
status: SystemStatus.RUNNING,
|
||||
setDomain: (domain?: string) => set((state) => ({ ...state, domain: domain || '' })),
|
||||
setBaseUrl: (url: string) => set((state) => ({ ...state, baseUrl: url })),
|
||||
setInternalIp: (ip: string) => set((state) => ({ ...state, internalIp: ip })),
|
||||
setStatus: (status: SystemStatus) => set((state) => ({ ...state, status })),
|
||||
}));
|
||||
|
|
|
@ -14,7 +14,8 @@
|
|||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"strictNullChecks": true
|
||||
"strictNullChecks": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
|
|
|
@ -19,4 +19,7 @@ module.exports = {
|
|||
'no-unused-vars': [1, { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-unused-vars': [1, { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
globals: {
|
||||
NodeJS: true,
|
||||
},
|
||||
};
|
||||
|
|
3
packages/system-api/.gitignore
vendored
3
packages/system-api/.gitignore
vendored
|
@ -1,7 +1,10 @@
|
|||
node_modules/
|
||||
dist/
|
||||
|
||||
.DS_Store
|
||||
|
||||
# testing
|
||||
coverage/
|
||||
logs/
|
||||
sessions/
|
||||
.vscode
|
||||
|
|
|
@ -11,6 +11,7 @@ const fs: {
|
|||
copyFileSync: typeof copyFileSync;
|
||||
copySync: typeof copyFileSync;
|
||||
createFileSync: typeof createFileSync;
|
||||
unlinkSync: typeof unlinkSync;
|
||||
} = jest.genMockFromModule('fs-extra');
|
||||
|
||||
let mockFiles = Object.create(null);
|
||||
|
@ -97,6 +98,16 @@ const resetAllMocks = () => {
|
|||
mockFiles = Object.create(null);
|
||||
};
|
||||
|
||||
const unlinkSync = (p: string) => {
|
||||
if (mockFiles[p] instanceof Array) {
|
||||
mockFiles[p].forEach((file: string) => {
|
||||
delete mockFiles[path.join(p, file)];
|
||||
});
|
||||
}
|
||||
delete mockFiles[p];
|
||||
};
|
||||
|
||||
fs.unlinkSync = unlinkSync;
|
||||
fs.readdirSync = readdirSync;
|
||||
fs.existsSync = existsSync;
|
||||
fs.readFileSync = readFileSync;
|
||||
|
|
11
packages/system-api/__mocks__/node-cron.ts
Normal file
11
packages/system-api/__mocks__/node-cron.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
const cron: {
|
||||
schedule: typeof schedule;
|
||||
} = jest.genMockFromModule('node-cron');
|
||||
|
||||
const schedule = (scd: string, cb: () => void) => {
|
||||
cb();
|
||||
};
|
||||
|
||||
cron.schedule = schedule;
|
||||
|
||||
module.exports = cron;
|
|
@ -7,9 +7,15 @@ module.exports = {
|
|||
setupFiles: ['<rootDir>/src/test/dotenv-config.ts'],
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test/jest-setup.ts'],
|
||||
collectCoverage: true,
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/migrations/**/*.{ts,tsx}', '!**/config/**/*.{ts,tsx}', '!**/__tests__/**'],
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/migrations/**/*.{ts,tsx}', '!**/src/config/**/*.{ts,tsx}', '!**/__tests__/**'],
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.graphql$': 'graphql-import-node/jest',
|
||||
},
|
||||
globals: {
|
||||
// NODE_ENV: 'test',
|
||||
'ts-jest': {
|
||||
isolatedModules: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "system-api",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"description": "",
|
||||
"exports": "./dist/server.js",
|
||||
"type": "module",
|
||||
|
@ -30,8 +30,6 @@
|
|||
"argon2": "^0.29.1",
|
||||
"axios": "^0.26.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.17.3",
|
||||
|
@ -41,39 +39,33 @@
|
|||
"graphql-type-json": "^0.3.2",
|
||||
"http": "0.0.1-security",
|
||||
"internal-ip": "^6.0.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"mock-fs": "^5.1.2",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-cron": "^3.0.1",
|
||||
"node-port-scanner": "^3.0.1",
|
||||
"p-iteration": "^1.1.8",
|
||||
"pg": "^8.7.3",
|
||||
"public-ip": "^5.0.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"semver": "^7.3.7",
|
||||
"session-file-store": "^1.5.0",
|
||||
"systeminformation": "^5.11.9",
|
||||
"tcp-port-used": "^1.0.2",
|
||||
"type-graphql": "^1.1.1",
|
||||
"typeorm": "^0.3.6",
|
||||
"validator": "^13.7.0",
|
||||
"winston": "^3.7.2"
|
||||
"winston": "^3.7.2",
|
||||
"zod": "^3.19.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^7.3.0",
|
||||
"@swc/cli": "^0.1.57",
|
||||
"@swc/core": "^1.2.210",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/jest": "^27.5.0",
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "17.0.31",
|
||||
"@types/node-cron": "^3.0.2",
|
||||
"@types/pg": "^8.6.5",
|
||||
"@types/semver": "^7.3.12",
|
||||
"@types/session-file-store": "^1.2.2",
|
||||
"@types/tcp-port-used": "^1.0.1",
|
||||
"@types/validator": "^13.7.2",
|
||||
|
|
|
@ -1,58 +0,0 @@
|
|||
import * as dotenv from 'dotenv';
|
||||
|
||||
interface IConfig {
|
||||
logs: {
|
||||
LOGS_FOLDER: string;
|
||||
LOGS_APP: string;
|
||||
LOGS_ERROR: string;
|
||||
};
|
||||
NODE_ENV: string;
|
||||
ROOT_FOLDER: string;
|
||||
JWT_SECRET: string;
|
||||
CLIENT_URLS: string[];
|
||||
VERSION: string;
|
||||
ROOT_FOLDER_HOST: string;
|
||||
APPS_REPO_ID: string;
|
||||
APPS_REPO_URL: string;
|
||||
INTERNAL_IP: string;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
dotenv.config({ path: '.env.dev' });
|
||||
} else {
|
||||
dotenv.config({ path: '.env' });
|
||||
}
|
||||
|
||||
const {
|
||||
LOGS_FOLDER = 'logs',
|
||||
LOGS_APP = 'app.log',
|
||||
LOGS_ERROR = 'error.log',
|
||||
NODE_ENV = 'development',
|
||||
JWT_SECRET = '',
|
||||
INTERNAL_IP = '',
|
||||
TIPI_VERSION = '',
|
||||
ROOT_FOLDER_HOST = '',
|
||||
NGINX_PORT = '80',
|
||||
APPS_REPO_ID = '',
|
||||
APPS_REPO_URL = '',
|
||||
DOMAIN = '',
|
||||
} = process.env;
|
||||
|
||||
const config: IConfig = {
|
||||
logs: {
|
||||
LOGS_FOLDER,
|
||||
LOGS_APP,
|
||||
LOGS_ERROR,
|
||||
},
|
||||
NODE_ENV,
|
||||
ROOT_FOLDER: '/tipi',
|
||||
JWT_SECRET,
|
||||
CLIENT_URLS: ['http://localhost:3000', `http://${INTERNAL_IP}`, `http://${INTERNAL_IP}:${NGINX_PORT}`, `http://${INTERNAL_IP}:3000`, `https://${DOMAIN}`],
|
||||
VERSION: TIPI_VERSION,
|
||||
ROOT_FOLDER_HOST,
|
||||
APPS_REPO_ID,
|
||||
APPS_REPO_URL,
|
||||
INTERNAL_IP,
|
||||
};
|
||||
|
||||
export default config;
|
|
@ -1 +0,0 @@
|
|||
export { default } from './config';
|
|
@ -1,13 +1,13 @@
|
|||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { createLogger, format, transports } from 'winston';
|
||||
import config from '..';
|
||||
import { getConfig } from '../../core/config/TipiConfig';
|
||||
|
||||
const { align, printf, timestamp, combine, colorize } = format;
|
||||
|
||||
// Create the logs directory if it does not exist
|
||||
if (!fs.existsSync(config.logs.LOGS_FOLDER)) {
|
||||
fs.mkdirSync(config.logs.LOGS_FOLDER);
|
||||
if (!fs.existsSync(getConfig().logs.LOGS_FOLDER)) {
|
||||
fs.mkdirSync(getConfig().logs.LOGS_FOLDER);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -36,14 +36,14 @@ const Logger = createLogger({
|
|||
// - Write all logs error (and below) to `error.log`.
|
||||
//
|
||||
new transports.File({
|
||||
filename: path.join(config.logs.LOGS_FOLDER, config.logs.LOGS_ERROR),
|
||||
filename: path.join(getConfig().logs.LOGS_FOLDER, getConfig().logs.LOGS_ERROR),
|
||||
level: 'error',
|
||||
}),
|
||||
new transports.File({
|
||||
filename: path.join(config.logs.LOGS_FOLDER, config.logs.LOGS_APP),
|
||||
filename: path.join(getConfig().logs.LOGS_FOLDER, getConfig().logs.LOGS_APP),
|
||||
}),
|
||||
],
|
||||
exceptionHandlers: [new transports.File({ filename: path.join(config.logs.LOGS_FOLDER, config.logs.LOGS_ERROR) })],
|
||||
exceptionHandlers: [new transports.File({ filename: path.join(getConfig().logs.LOGS_FOLDER, getConfig().logs.LOGS_ERROR) })],
|
||||
});
|
||||
|
||||
//
|
||||
|
@ -59,4 +59,4 @@ const LoggerDev = createLogger({
|
|||
],
|
||||
});
|
||||
|
||||
export default config.NODE_ENV === 'production' ? Logger : LoggerDev;
|
||||
export default process.env.NODE_ENV === 'production' ? Logger : LoggerDev;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import config from '../config';
|
||||
import { getConfig } from '../core/config/TipiConfig';
|
||||
|
||||
export const APP_DATA_FOLDER = 'app-data';
|
||||
export const APPS_FOLDER = 'apps';
|
||||
export const isProd = config.NODE_ENV === 'production';
|
||||
export const isProd = getConfig().NODE_ENV === 'production';
|
||||
|
|
227
packages/system-api/src/core/config/EventDispatcher.ts
Normal file
227
packages/system-api/src/core/config/EventDispatcher.ts
Normal file
|
@ -0,0 +1,227 @@
|
|||
import fs from 'fs-extra';
|
||||
import logger from '../../config/logger/logger';
|
||||
|
||||
export enum EventTypes {
|
||||
// System events
|
||||
RESTART = 'restart',
|
||||
UPDATE = 'update',
|
||||
CLONE_REPO = 'clone_repo',
|
||||
UPDATE_REPO = 'update_repo',
|
||||
APP = 'app',
|
||||
SYSTEM_INFO = 'system_info',
|
||||
}
|
||||
|
||||
type SystemEvent = {
|
||||
id: string;
|
||||
type: EventTypes;
|
||||
args: string[];
|
||||
creationDate: Date;
|
||||
};
|
||||
|
||||
type EventStatusTypes = 'running' | 'success' | 'error' | 'waiting';
|
||||
|
||||
const WATCH_FILE = '/runtipi/state/events';
|
||||
|
||||
// File state example:
|
||||
// restart 1631231231231 running "arg1 arg2"
|
||||
class EventDispatcher {
|
||||
private static instance: EventDispatcher | null;
|
||||
|
||||
private queue: SystemEvent[] = [];
|
||||
|
||||
private lock: SystemEvent | null = null;
|
||||
|
||||
private interval: NodeJS.Timer;
|
||||
|
||||
private intervals: NodeJS.Timer[] = [];
|
||||
|
||||
constructor() {
|
||||
const timer = this.pollQueue();
|
||||
this.interval = timer;
|
||||
}
|
||||
|
||||
public static getInstance(): EventDispatcher {
|
||||
if (!EventDispatcher.instance) {
|
||||
EventDispatcher.instance = new EventDispatcher();
|
||||
}
|
||||
return EventDispatcher.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random task id
|
||||
* @returns - Random id
|
||||
*/
|
||||
private generateId() {
|
||||
return Math.random().toString(36).substring(2, 9);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect lock status and clean queue if event is done
|
||||
*/
|
||||
private collectLockStatusAndClean() {
|
||||
if (!this.lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = this.getEventStatus(this.lock.id);
|
||||
|
||||
if (status === 'running' || status === 'waiting') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearEvent(this.lock, status);
|
||||
this.lock = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll queue and run events
|
||||
*/
|
||||
private pollQueue() {
|
||||
logger.info('EventDispatcher: Polling queue...');
|
||||
|
||||
if (!this.interval) {
|
||||
const id = setInterval(() => {
|
||||
this.runEvent();
|
||||
this.collectLockStatusAndClean();
|
||||
}, 1000);
|
||||
this.intervals.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
return this.interval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run event from the queue if there is no lock
|
||||
*/
|
||||
private async runEvent() {
|
||||
if (this.lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = this.queue[0];
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lock = event;
|
||||
|
||||
// Write event to state file
|
||||
const args = event.args.join(' ');
|
||||
const line = `${event.type} ${event.id} waiting ${args}`;
|
||||
fs.writeFileSync(WATCH_FILE, `${line}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check event status
|
||||
* @param id - Event id
|
||||
* @returns - Event status
|
||||
*/
|
||||
private getEventStatus(id: string): EventStatusTypes {
|
||||
const event = this.queue.find((e) => e.id === id);
|
||||
|
||||
if (!event) {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
// if event was created more than 3 minutes ago, it's an error
|
||||
if (new Date().getTime() - event.creationDate.getTime() > 5 * 60 * 1000) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
const file = fs.readFileSync(WATCH_FILE, 'utf8');
|
||||
const lines = file?.split('\n') || [];
|
||||
const line = lines.find((l) => l.startsWith(`${event.type} ${event.id}`));
|
||||
|
||||
if (!line) {
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
const status = line.split(' ')[2] as EventStatusTypes;
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event to the queue
|
||||
* @param type - Event type
|
||||
* @param args - Event arguments
|
||||
* @returns - Event object
|
||||
*/
|
||||
public dispatchEvent(type: EventTypes, args?: string[]): SystemEvent {
|
||||
const event: SystemEvent = {
|
||||
id: this.generateId(),
|
||||
type,
|
||||
args: args || [],
|
||||
creationDate: new Date(),
|
||||
};
|
||||
|
||||
this.queue.push(event);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear event from queue
|
||||
* @param id - Event id
|
||||
*/
|
||||
private clearEvent(event: SystemEvent, status: EventStatusTypes = 'success') {
|
||||
this.queue = this.queue.filter((e) => e.id !== event.id);
|
||||
if (fs.existsSync(`/app/logs/${event.id}.log`)) {
|
||||
const log = fs.readFileSync(`/app/logs/${event.id}.log`, 'utf8');
|
||||
if (log && status === 'error') {
|
||||
logger.error(`EventDispatcher: ${event.type} ${event.id} failed with error: ${log}`);
|
||||
} else if (log) {
|
||||
logger.info(`EventDispatcher: ${event.type} ${event.id} finished with message: ${log}`);
|
||||
}
|
||||
fs.unlinkSync(`/app/logs/${event.id}.log`);
|
||||
}
|
||||
fs.writeFileSync(WATCH_FILE, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event to the queue and wait for it to finish
|
||||
* @param type - Event type
|
||||
* @param args - Event arguments
|
||||
* @returns - Promise that resolves when the event is done
|
||||
*/
|
||||
public async dispatchEventAsync(type: EventTypes, args?: string[]): Promise<{ success: boolean; stdout?: string }> {
|
||||
const event = this.dispatchEvent(type, args);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
this.intervals.push(interval);
|
||||
const status = this.getEventStatus(event.id);
|
||||
|
||||
let log = '';
|
||||
if (fs.existsSync(`/app/logs/${event.id}.log`)) {
|
||||
log = fs.readFileSync(`/app/logs/${event.id}.log`, 'utf8');
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
clearInterval(interval);
|
||||
resolve({ success: true, stdout: log });
|
||||
} else if (status === 'error') {
|
||||
clearInterval(interval);
|
||||
resolve({ success: false, stdout: log });
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
public clearInterval() {
|
||||
clearInterval(this.interval);
|
||||
this.intervals.forEach((i) => clearInterval(i));
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.queue = [];
|
||||
this.lock = null;
|
||||
EventDispatcher.instance = null;
|
||||
fs.writeFileSync(WATCH_FILE, '');
|
||||
}
|
||||
}
|
||||
|
||||
export const eventDispatcher = EventDispatcher.getInstance();
|
||||
|
||||
export default EventDispatcher;
|
124
packages/system-api/src/core/config/TipiConfig.ts
Normal file
124
packages/system-api/src/core/config/TipiConfig.ts
Normal file
|
@ -0,0 +1,124 @@
|
|||
import { z } from 'zod';
|
||||
import * as dotenv from 'dotenv';
|
||||
import fs from 'fs-extra';
|
||||
import { readJsonFile } from '../../modules/fs/fs.helpers';
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
dotenv.config({ path: '.env.dev' });
|
||||
} else {
|
||||
dotenv.config({ path: '.env' });
|
||||
}
|
||||
const {
|
||||
LOGS_FOLDER = '/app/logs',
|
||||
LOGS_APP = 'app.log',
|
||||
LOGS_ERROR = 'error.log',
|
||||
NODE_ENV = 'development',
|
||||
JWT_SECRET = '',
|
||||
INTERNAL_IP = '',
|
||||
TIPI_VERSION = '',
|
||||
NGINX_PORT = '80',
|
||||
APPS_REPO_ID = '',
|
||||
APPS_REPO_URL = '',
|
||||
DOMAIN = '',
|
||||
STORAGE_PATH = '/runtipi',
|
||||
} = process.env;
|
||||
|
||||
const configSchema = z.object({
|
||||
NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]),
|
||||
status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]),
|
||||
logs: z.object({
|
||||
LOGS_FOLDER: z.string(),
|
||||
LOGS_APP: z.string(),
|
||||
LOGS_ERROR: z.string(),
|
||||
}),
|
||||
dnsIp: z.string(),
|
||||
rootFolder: z.string(),
|
||||
internalIp: z.string(),
|
||||
version: z.string(),
|
||||
jwtSecret: z.string(),
|
||||
clientUrls: z.array(z.string()),
|
||||
appsRepoId: z.string(),
|
||||
appsRepoUrl: z.string(),
|
||||
domain: z.string(),
|
||||
storagePath: z.string(),
|
||||
});
|
||||
|
||||
class Config {
|
||||
private static instance: Config;
|
||||
|
||||
private config: z.infer<typeof configSchema>;
|
||||
|
||||
constructor() {
|
||||
const envConfig: z.infer<typeof configSchema> = {
|
||||
logs: {
|
||||
LOGS_FOLDER,
|
||||
LOGS_APP,
|
||||
LOGS_ERROR,
|
||||
},
|
||||
NODE_ENV: NODE_ENV as z.infer<typeof configSchema>['NODE_ENV'],
|
||||
rootFolder: '/runtipi',
|
||||
internalIp: INTERNAL_IP,
|
||||
version: TIPI_VERSION,
|
||||
jwtSecret: JWT_SECRET,
|
||||
clientUrls: ['http://localhost:3000', `http://${INTERNAL_IP}`, `http://${INTERNAL_IP}:${NGINX_PORT}`, `http://${INTERNAL_IP}:3000`, DOMAIN && `https://${DOMAIN}`].filter(Boolean),
|
||||
appsRepoId: APPS_REPO_ID,
|
||||
appsRepoUrl: APPS_REPO_URL,
|
||||
domain: DOMAIN,
|
||||
dnsIp: '9.9.9.9',
|
||||
status: 'RUNNING',
|
||||
storagePath: STORAGE_PATH,
|
||||
};
|
||||
|
||||
const parsed = configSchema.parse({
|
||||
...envConfig,
|
||||
});
|
||||
|
||||
this.config = parsed;
|
||||
}
|
||||
|
||||
public static getInstance(): Config {
|
||||
if (!Config.instance) {
|
||||
Config.instance = new Config();
|
||||
}
|
||||
return Config.instance;
|
||||
}
|
||||
|
||||
public getConfig() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
public applyJsonConfig() {
|
||||
const fileConfig = readJsonFile('/runtipi/state/settings.json') || {};
|
||||
|
||||
const parsed = configSchema.parse({
|
||||
...this.config,
|
||||
...fileConfig,
|
||||
});
|
||||
|
||||
this.config = parsed;
|
||||
}
|
||||
|
||||
public setConfig<T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile: boolean = false) {
|
||||
const newConf: z.infer<typeof configSchema> = { ...this.getConfig() };
|
||||
newConf[key] = value;
|
||||
|
||||
this.config = configSchema.parse(newConf);
|
||||
|
||||
if (writeFile) {
|
||||
const currentJsonConf = readJsonFile('/runtipi/state/settings.json') || {};
|
||||
currentJsonConf[key] = value;
|
||||
const partialConfig = configSchema.partial();
|
||||
const parsed = partialConfig.parse(currentJsonConf);
|
||||
|
||||
fs.writeFileSync('/runtipi/state/settings.json', JSON.stringify(parsed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const setConfig = <T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile: boolean = false) => {
|
||||
Config.getInstance().setConfig(key, value, writeFile);
|
||||
};
|
||||
|
||||
export const getConfig = () => Config.getInstance().getConfig();
|
||||
|
||||
export const applyJsonConfig = () => Config.getInstance().applyJsonConfig();
|
|
@ -0,0 +1,199 @@
|
|||
import fs from 'fs-extra';
|
||||
import { eventDispatcher, EventTypes } from '../EventDispatcher';
|
||||
|
||||
const WATCH_FILE = '/runtipi/state/events';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
beforeEach(() => {
|
||||
eventDispatcher.clear();
|
||||
fs.writeFileSync(WATCH_FILE, '');
|
||||
fs.writeFileSync('/app/logs/123.log', 'test');
|
||||
});
|
||||
|
||||
describe('EventDispatcher - dispatchEvent', () => {
|
||||
it('should dispatch an event', () => {
|
||||
const event = eventDispatcher.dispatchEvent(EventTypes.APP);
|
||||
expect(event.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should dispatch an event with args', () => {
|
||||
const event = eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
|
||||
expect(event.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('Should put events into queue', async () => {
|
||||
eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
|
||||
eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
|
||||
|
||||
// @ts-ignore
|
||||
const queue = eventDispatcher.queue;
|
||||
|
||||
expect(queue.length).toBe(2);
|
||||
});
|
||||
|
||||
it('Should put first event into lock after 1 sec', async () => {
|
||||
eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
|
||||
eventDispatcher.dispatchEvent(EventTypes.UPDATE, ['--help']);
|
||||
|
||||
// @ts-ignore
|
||||
const queue = eventDispatcher.queue;
|
||||
|
||||
await wait(1050);
|
||||
|
||||
// @ts-ignore
|
||||
const lock = eventDispatcher.lock;
|
||||
|
||||
expect(queue.length).toBe(2);
|
||||
expect(lock).toBeDefined();
|
||||
expect(lock?.type).toBe(EventTypes.APP);
|
||||
});
|
||||
|
||||
it('Should clear event once its status is success', async () => {
|
||||
// @ts-ignore
|
||||
jest.spyOn(eventDispatcher, 'getEventStatus').mockReturnValueOnce('success');
|
||||
eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
|
||||
|
||||
await wait(1050);
|
||||
|
||||
// @ts-ignore
|
||||
const queue = eventDispatcher.queue;
|
||||
|
||||
expect(queue.length).toBe(0);
|
||||
});
|
||||
|
||||
it('Should clear event once its status is error', async () => {
|
||||
// @ts-ignore
|
||||
jest.spyOn(eventDispatcher, 'getEventStatus').mockReturnValueOnce('error');
|
||||
eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
|
||||
|
||||
await wait(1050);
|
||||
|
||||
// @ts-ignore
|
||||
const queue = eventDispatcher.queue;
|
||||
|
||||
expect(queue.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventDispatcher - dispatchEventAsync', () => {
|
||||
it('Should dispatch an event and wait for it to finish', async () => {
|
||||
// @ts-ignore
|
||||
jest.spyOn(eventDispatcher, 'getEventStatus').mockReturnValueOnce('success');
|
||||
const { success } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['--help']);
|
||||
|
||||
expect(success).toBe(true);
|
||||
});
|
||||
|
||||
it('Should dispatch an event and wait for it to finish with error', async () => {
|
||||
// @ts-ignore
|
||||
jest.spyOn(eventDispatcher, 'getEventStatus').mockReturnValueOnce('error');
|
||||
|
||||
const { success } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['--help']);
|
||||
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventDispatcher - runEvent', () => {
|
||||
it('Should do nothing if there is a lock', async () => {
|
||||
// @ts-ignore
|
||||
eventDispatcher.lock = { id: '123', type: EventTypes.APP, args: [] };
|
||||
|
||||
// @ts-ignore
|
||||
await eventDispatcher.runEvent();
|
||||
|
||||
// @ts-ignore
|
||||
const file = fs.readFileSync(WATCH_FILE, 'utf8');
|
||||
|
||||
expect(file).toBe('');
|
||||
});
|
||||
|
||||
it('Should do nothing if there is no event in queue', async () => {
|
||||
// @ts-ignore
|
||||
await eventDispatcher.runEvent();
|
||||
|
||||
// @ts-ignore
|
||||
const file = fs.readFileSync(WATCH_FILE, 'utf8');
|
||||
|
||||
expect(file).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventDispatcher - getEventStatus', () => {
|
||||
it('Should return success if event is not in the queue', async () => {
|
||||
// @ts-ignore
|
||||
eventDispatcher.queue = [];
|
||||
// @ts-ignore
|
||||
const status = eventDispatcher.getEventStatus('123');
|
||||
|
||||
expect(status).toBe('success');
|
||||
});
|
||||
|
||||
it('Should return error if event is expired', async () => {
|
||||
const dateFiveMinutesAgo = new Date(new Date().getTime() - 5 * 60 * 10000);
|
||||
// @ts-ignore
|
||||
eventDispatcher.queue = [{ id: '123', type: EventTypes.APP, args: [], creationDate: dateFiveMinutesAgo }];
|
||||
// @ts-ignore
|
||||
const status = eventDispatcher.getEventStatus('123');
|
||||
|
||||
expect(status).toBe('error');
|
||||
});
|
||||
|
||||
it('Should be waiting if line is not found in the file', async () => {
|
||||
// @ts-ignore
|
||||
eventDispatcher.queue = [{ id: '123', type: EventTypes.APP, args: [], creationDate: new Date() }];
|
||||
// @ts-ignore
|
||||
const status = eventDispatcher.getEventStatus('123');
|
||||
|
||||
expect(status).toBe('waiting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventDispatcher - clearEvent', () => {
|
||||
it('Should clear event', async () => {
|
||||
const event = { id: '123', type: EventTypes.APP, args: [], creationDate: new Date() };
|
||||
// @ts-ignore
|
||||
eventDispatcher.queue = [event];
|
||||
// @ts-ignore
|
||||
eventDispatcher.clearEvent(event);
|
||||
|
||||
// @ts-ignore
|
||||
const queue = eventDispatcher.queue;
|
||||
|
||||
expect(queue.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventDispatcher - pollQueue', () => {
|
||||
it('Should not create a new interval if one already exists', async () => {
|
||||
// @ts-ignore
|
||||
eventDispatcher.interval = 123;
|
||||
// @ts-ignore
|
||||
const id = eventDispatcher.pollQueue();
|
||||
// @ts-ignore
|
||||
const interval = eventDispatcher.interval;
|
||||
|
||||
expect(interval).toBe(123);
|
||||
expect(id).toBe(123);
|
||||
|
||||
clearInterval(interval);
|
||||
clearInterval(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventDispatcher - collectLockStatusAndClean', () => {
|
||||
it('Should do nothing if there is no lock', async () => {
|
||||
// @ts-ignore
|
||||
eventDispatcher.lock = null;
|
||||
// @ts-ignore
|
||||
eventDispatcher.collectLockStatusAndClean();
|
||||
|
||||
// @ts-ignore
|
||||
const lock = eventDispatcher.lock;
|
||||
|
||||
expect(lock).toBeNull();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,99 @@
|
|||
import { faker } from '@faker-js/faker';
|
||||
import fs from 'fs-extra';
|
||||
import { readJsonFile } from '../../../modules/fs/fs.helpers';
|
||||
import { applyJsonConfig, getConfig, setConfig } from '../TipiConfig';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('Test: getConfig', () => {
|
||||
it('It should return config from .env', () => {
|
||||
const config = getConfig();
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config.NODE_ENV).toBe('test');
|
||||
expect(config.logs.LOGS_FOLDER).toBe('/app/logs');
|
||||
expect(config.logs.LOGS_APP).toBe('app.log');
|
||||
expect(config.logs.LOGS_ERROR).toBe('error.log');
|
||||
expect(config.dnsIp).toBe('9.9.9.9');
|
||||
expect(config.rootFolder).toBe('/runtipi');
|
||||
expect(config.internalIp).toBe('192.168.1.10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: setConfig', () => {
|
||||
it('It should be able set config', () => {
|
||||
const randomWord = faker.random.word();
|
||||
setConfig('appsRepoUrl', randomWord);
|
||||
const config = getConfig();
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config.appsRepoUrl).toBe(randomWord);
|
||||
});
|
||||
|
||||
it('Should not be able to set invalid NODE_ENV', () => {
|
||||
// @ts-ignore
|
||||
expect(() => setConfig('NODE_ENV', 'invalid')).toThrow();
|
||||
});
|
||||
|
||||
it('Should write config to json file', () => {
|
||||
const randomWord = faker.random.word();
|
||||
setConfig('appsRepoUrl', randomWord, true);
|
||||
const config = getConfig();
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config.appsRepoUrl).toBe(randomWord);
|
||||
|
||||
const settingsJson = readJsonFile('/runtipi/state/settings.json');
|
||||
|
||||
expect(settingsJson).toBeDefined();
|
||||
expect(settingsJson.appsRepoUrl).toBe(randomWord);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: applyJsonConfig', () => {
|
||||
it('It should be able to apply json config', () => {
|
||||
const settingsJson = {
|
||||
appsRepoUrl: faker.random.word(),
|
||||
appsRepoId: faker.random.word(),
|
||||
domain: faker.random.word(),
|
||||
};
|
||||
|
||||
const MockFiles = {
|
||||
'/runtipi/state/settings.json': JSON.stringify(settingsJson),
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
applyJsonConfig();
|
||||
const config = getConfig();
|
||||
|
||||
expect(config).toBeDefined();
|
||||
|
||||
expect(config.appsRepoUrl).toBe(settingsJson.appsRepoUrl);
|
||||
expect(config.appsRepoId).toBe(settingsJson.appsRepoId);
|
||||
expect(config.domain).toBe(settingsJson.domain);
|
||||
});
|
||||
|
||||
it('Should not be able to apply an invalid value from json config', () => {
|
||||
const settingsJson = {
|
||||
appsRepoUrl: faker.random.word(),
|
||||
appsRepoId: faker.random.word(),
|
||||
domain: 10,
|
||||
};
|
||||
|
||||
const MockFiles = {
|
||||
'/runtipi/state/settings.json': JSON.stringify(settingsJson),
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
expect(() => applyJsonConfig()).toThrow();
|
||||
});
|
||||
});
|
37
packages/system-api/src/core/jobs/__tests__/jobs.test.ts
Normal file
37
packages/system-api/src/core/jobs/__tests__/jobs.test.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import cron from 'node-cron';
|
||||
import { getConfig } from '../../config/TipiConfig';
|
||||
import startJobs from '../jobs';
|
||||
import { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher';
|
||||
|
||||
jest.mock('node-cron');
|
||||
jest.mock('child_process');
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('Test: startJobs', () => {
|
||||
it('Should start cron jobs', () => {
|
||||
const spy = jest.spyOn(cron, 'schedule');
|
||||
|
||||
startJobs();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith('*/30 * * * *', expect.any(Function));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should update apps repo on cron trigger', () => {
|
||||
const spy = jest.spyOn(eventDispatcher, 'dispatchEvent');
|
||||
|
||||
// Act
|
||||
startJobs();
|
||||
|
||||
// Assert
|
||||
expect(spy.mock.calls.length).toBe(2);
|
||||
expect(spy.mock.calls[0]).toEqual([EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]]);
|
||||
expect(spy.mock.calls[1]).toEqual([EventTypes.SYSTEM_INFO, []]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
|
@ -1,14 +1,19 @@
|
|||
import cron from 'node-cron';
|
||||
import config from '../../config';
|
||||
import logger from '../../config/logger/logger';
|
||||
import { updateRepo } from '../../helpers/repo-helpers';
|
||||
import { getConfig } from '../../core/config/TipiConfig';
|
||||
import { eventDispatcher, EventTypes } from '../config/EventDispatcher';
|
||||
|
||||
const startJobs = () => {
|
||||
logger.info('Starting cron jobs...');
|
||||
|
||||
cron.schedule('0 * * * *', () => {
|
||||
logger.info('Cloning apps repo...');
|
||||
updateRepo(config.APPS_REPO_URL);
|
||||
// Every 30 minutes
|
||||
cron.schedule('*/30 * * * *', async () => {
|
||||
eventDispatcher.dispatchEvent(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]);
|
||||
});
|
||||
|
||||
// every minute
|
||||
cron.schedule('* * * * *', () => {
|
||||
eventDispatcher.dispatchEvent(EventTypes.SYSTEM_INFO, []);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import session from 'express-session';
|
||||
import config from '../../config';
|
||||
import SessionFileStore from 'session-file-store';
|
||||
import { COOKIE_MAX_AGE, __prod__ } from '../../config/constants/constants';
|
||||
import { getConfig } from '../config/TipiConfig';
|
||||
|
||||
const getSessionMiddleware = () => {
|
||||
const FileStore = SessionFileStore(session);
|
||||
|
@ -12,7 +12,7 @@ const getSessionMiddleware = () => {
|
|||
name: 'qid',
|
||||
store: new FileStore(),
|
||||
cookie: { maxAge: COOKIE_MAX_AGE, secure: false, sameSite, httpOnly: true },
|
||||
secret: config.JWT_SECRET,
|
||||
secret: getConfig().jwtSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
});
|
||||
|
|
|
@ -6,6 +6,7 @@ import { AppInfo, AppStatusEnum } from '../../../modules/apps/apps.types';
|
|||
import { createApp } from '../../../modules/apps/__tests__/apps.factory';
|
||||
import Update, { UpdateStatusEnum } from '../../../modules/system/update.entity';
|
||||
import { setupConnection, teardownConnection } from '../../../test/connection';
|
||||
import { getConfig } from '../../config/TipiConfig';
|
||||
import { updateV040 } from '../v040';
|
||||
|
||||
jest.mock('fs');
|
||||
|
@ -61,7 +62,7 @@ describe('No state/apps.json', () => {
|
|||
describe('State/apps.json exists with no installed app', () => {
|
||||
beforeEach(async () => {
|
||||
const { MockFiles } = await createApp({});
|
||||
MockFiles['/tipi/state/apps.json'] = createState([]);
|
||||
MockFiles[`${getConfig().rootFolder}/state/apps.json`] = createState([]);
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
});
|
||||
|
@ -79,7 +80,7 @@ describe('State/apps.json exists with no installed app', () => {
|
|||
|
||||
it('Should delete state file after update', async () => {
|
||||
await updateV040();
|
||||
expect(fs.existsSync('/tipi/state/apps.json')).toBe(false);
|
||||
expect(fs.existsSync('/runtipi/state/apps.json')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -88,9 +89,9 @@ describe('State/apps.json exists with one installed app', () => {
|
|||
beforeEach(async () => {
|
||||
const { MockFiles, appInfo } = await createApp({});
|
||||
app1 = appInfo;
|
||||
MockFiles['/tipi/state/apps.json'] = createState([appInfo.id]);
|
||||
MockFiles[`/tipi/app-data/${appInfo.id}`] = '';
|
||||
MockFiles[`/tipi/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
|
||||
MockFiles['/runtipi/state/apps.json'] = createState([appInfo.id]);
|
||||
MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
|
||||
MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
});
|
||||
|
@ -117,9 +118,9 @@ describe('State/apps.json exists with one installed app', () => {
|
|||
it('Should not try to migrate app if it already exists', async () => {
|
||||
const { MockFiles, appInfo } = await createApp({ installed: true });
|
||||
app1 = appInfo;
|
||||
MockFiles['/tipi/state/apps.json'] = createState([appInfo.id]);
|
||||
MockFiles[`/tipi/app-data/${appInfo.id}`] = '';
|
||||
MockFiles[`/tipi/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
|
||||
MockFiles['/runtipi/state/apps.json'] = createState([appInfo.id]);
|
||||
MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
|
||||
MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import datasource from '../../config/datasource';
|
||||
import { DataSource } from 'typeorm';
|
||||
import logger from '../../config/logger/logger';
|
||||
import App from '../../modules/apps/app.entity';
|
||||
import User from '../../modules/auth/user.entity';
|
||||
import Update from '../../modules/system/update.entity';
|
||||
|
||||
const recover = async () => {
|
||||
const recover = async (datasource: DataSource) => {
|
||||
logger.info('Recovering broken database');
|
||||
|
||||
const queryRunner = datasource.createQueryRunner();
|
||||
|
@ -33,9 +33,10 @@ const recover = async () => {
|
|||
await Update.create(update).save();
|
||||
}
|
||||
|
||||
logger.info('Users recovered', users.length);
|
||||
logger.info('Apps recovered', apps.length);
|
||||
logger.info('Database recovered');
|
||||
logger.info(`Users recovered ${users.length}`);
|
||||
logger.info(`Apps recovered ${apps.length}`);
|
||||
logger.info(`Updates recovered ${updates.length}`);
|
||||
logger.info('Database fully recovered');
|
||||
};
|
||||
|
||||
export default recover;
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import config from '../../config';
|
||||
import logger from '../../config/logger/logger';
|
||||
import App from '../../modules/apps/app.entity';
|
||||
import { AppInfo, AppStatusEnum } from '../../modules/apps/apps.types';
|
||||
import User from '../../modules/auth/user.entity';
|
||||
import { deleteFolder, fileExists, readFile, readJsonFile } from '../../modules/fs/fs.helpers';
|
||||
import Update, { UpdateStatusEnum } from '../../modules/system/update.entity';
|
||||
import { getConfig } from '../config/TipiConfig';
|
||||
|
||||
type AppsState = { installed: string };
|
||||
|
||||
|
@ -20,15 +20,15 @@ export const updateV040 = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
// Migrate apps
|
||||
if (fileExists('/state/apps.json')) {
|
||||
const state: AppsState = await readJsonFile('/state/apps.json');
|
||||
if (fileExists('/runtipi/state/apps.json')) {
|
||||
const state: AppsState = await readJsonFile('/runtipi/state/apps.json');
|
||||
const installed: string[] = state.installed.split(' ').filter(Boolean);
|
||||
|
||||
for (const appId of installed) {
|
||||
const app = await App.findOne({ where: { id: appId } });
|
||||
|
||||
if (!app) {
|
||||
const envFile = readFile(`/app-data/${appId}/app.env`).toString();
|
||||
const envFile = readFile(`/app/storage/app-data/${appId}/app.env`).toString();
|
||||
const envVars = envFile.split('\n');
|
||||
const envVarsMap = new Map<string, string>();
|
||||
|
||||
|
@ -39,7 +39,7 @@ export const updateV040 = async (): Promise<void> => {
|
|||
|
||||
const form: Record<string, string> = {};
|
||||
|
||||
const configFile: AppInfo | null = readJsonFile(`/repos/${config.APPS_REPO_ID}/apps/${appId}/config.json`);
|
||||
const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appId}/config.json`);
|
||||
configFile?.form_fields?.forEach((field) => {
|
||||
const envVar = field.env_variable;
|
||||
const envVarValue = envVarsMap.get(envVar);
|
||||
|
@ -54,23 +54,22 @@ export const updateV040 = async (): Promise<void> => {
|
|||
logger.info('App already migrated');
|
||||
}
|
||||
}
|
||||
deleteFolder('/state/apps.json');
|
||||
deleteFolder('/runtipi/state/apps.json');
|
||||
}
|
||||
|
||||
// Migrate users
|
||||
if (fileExists('/state/users.json')) {
|
||||
const state: { email: string; password: string }[] = await readJsonFile('/state/users.json');
|
||||
const state: { email: string; password: string }[] = await readJsonFile('/runtipi/state/users.json');
|
||||
|
||||
for (const user of state) {
|
||||
await User.create({ username: user.email.trim().toLowerCase(), password: user.password }).save();
|
||||
}
|
||||
deleteFolder('/state/users.json');
|
||||
deleteFolder('/runtipi/state/users.json');
|
||||
}
|
||||
|
||||
await Update.create({ name: UPDATE_NAME, status: UpdateStatusEnum.SUCCESS }).save();
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
console.error(error);
|
||||
await Update.create({ name: UPDATE_NAME, status: UpdateStatusEnum.FAILED }).save();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
import { runScript } from '../modules/fs/fs.helpers';
|
||||
|
||||
export const updateRepo = (repo: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
runScript('/scripts/git.sh', ['update', repo], (err: string, stdout: string) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
|
||||
console.info('Update result', stdout);
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const cloneRepo = (repo: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
runScript('/scripts/git.sh', ['clone', repo], (err: string, stdout: string) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
|
||||
console.info('Clone result', stdout);
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
|
@ -1,6 +1,5 @@
|
|||
import { faker } from '@faker-js/faker';
|
||||
import { AppCategoriesEnum, AppInfo, AppStatusEnum, FieldTypes } from '../apps.types';
|
||||
import config from '../../../config';
|
||||
import App from '../app.entity';
|
||||
|
||||
interface IProps {
|
||||
|
@ -55,11 +54,11 @@ const createApp = async (props: IProps) => {
|
|||
}
|
||||
|
||||
let MockFiles: any = {};
|
||||
MockFiles[`${config.ROOT_FOLDER}/.env`] = 'TEST=test';
|
||||
MockFiles[`${config.ROOT_FOLDER}/repos/repo-id`] = '';
|
||||
MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
|
||||
MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
|
||||
MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
|
||||
MockFiles['/runtipi/.env'] = 'TEST=test';
|
||||
MockFiles['/runtipi/repos/repo-id'] = '';
|
||||
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
|
||||
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
|
||||
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
|
||||
|
||||
let appEntity = new App();
|
||||
if (installed) {
|
||||
|
@ -71,10 +70,10 @@ const createApp = async (props: IProps) => {
|
|||
domain,
|
||||
}).save();
|
||||
|
||||
MockFiles[`${config.ROOT_FOLDER}/app-data/${appInfo.id}`] = '';
|
||||
MockFiles[`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
|
||||
MockFiles[`${config.ROOT_FOLDER}/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
|
||||
MockFiles[`${config.ROOT_FOLDER}/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
|
||||
MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
|
||||
MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
|
||||
MockFiles[`/runtipi/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
|
||||
MockFiles[`/runtipi/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
|
||||
}
|
||||
|
||||
return { appInfo, MockFiles, appEntity };
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { faker } from '@faker-js/faker';
|
||||
import fs from 'fs-extra';
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from '../../../config';
|
||||
import logger from '../../../config/logger/logger';
|
||||
import { setupConnection, teardownConnection } from '../../../test/connection';
|
||||
import App from '../app.entity';
|
||||
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo, runAppScript } from '../apps.helpers';
|
||||
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from '../apps.helpers';
|
||||
import { AppInfo } from '../apps.types';
|
||||
import { createApp } from './apps.factory';
|
||||
|
||||
|
@ -95,7 +95,7 @@ describe('checkEnvFile', () => {
|
|||
|
||||
it('Should throw if a required field is missing', () => {
|
||||
const newAppEnv = 'APP_PORT=test\n';
|
||||
fs.writeFileSync(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`, newAppEnv);
|
||||
fs.writeFileSync(`/app/storage/app-data/${app1.id}/app.env`, newAppEnv);
|
||||
|
||||
try {
|
||||
checkEnvFile(app1.id);
|
||||
|
@ -107,26 +107,7 @@ describe('checkEnvFile', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('runAppScript', () => {
|
||||
let app1: AppInfo;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app1create = await createApp({ installed: true });
|
||||
app1 = app1create.appInfo;
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(app1create.MockFiles);
|
||||
});
|
||||
|
||||
it('Should run the app script', async () => {
|
||||
const { MockFiles } = await createApp({ installed: true });
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
await runAppScript(['install', app1.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateEnvFile', () => {
|
||||
describe('Test: generateEnvFile', () => {
|
||||
let app1: AppInfo;
|
||||
let appEntity1: App;
|
||||
beforeEach(async () => {
|
||||
|
@ -167,7 +148,7 @@ describe('generateEnvFile', () => {
|
|||
|
||||
const randomField = faker.random.alphaNumeric(32);
|
||||
|
||||
fs.writeFileSync(`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`, `RANDOM_FIELD=${randomField}`);
|
||||
fs.writeFileSync(`/app/storage/app-data/${appInfo.id}/app.env`, `RANDOM_FIELD=${randomField}`);
|
||||
|
||||
generateEnvFile(appEntity);
|
||||
|
||||
|
@ -234,6 +215,18 @@ describe('generateEnvFile', () => {
|
|||
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
|
||||
expect(envmap.get('APP_DOMAIN')).toBe(`192.168.1.10:${appInfo.port}`);
|
||||
});
|
||||
|
||||
it('Should create app folder if it does not exist', async () => {
|
||||
const { appEntity, appInfo, MockFiles } = await createApp({ installed: true });
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
fs.rmSync(`/app/storage/app-data/${appInfo.id}`, { recursive: true });
|
||||
|
||||
generateEnvFile(appEntity);
|
||||
|
||||
expect(fs.existsSync(`/app/storage/app-data/${appInfo.id}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableApps', () => {
|
||||
|
@ -251,7 +244,7 @@ describe('getAvailableApps', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getAppInfo', () => {
|
||||
describe('Test: getAppInfo', () => {
|
||||
let app1: AppInfo;
|
||||
beforeEach(async () => {
|
||||
const app1create = await createApp({ installed: false });
|
||||
|
@ -267,15 +260,82 @@ describe('getAppInfo', () => {
|
|||
});
|
||||
|
||||
it('Should take config.json locally if app is installed', async () => {
|
||||
const { appInfo, MockFiles } = await createApp({ installed: true });
|
||||
const { appInfo, MockFiles, appEntity } = await createApp({ installed: true });
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
fs.writeFileSync(`${config.ROOT_FOLDER}/repos/repo-id/apps/${app1.id}/config.json`, '{}');
|
||||
const newConfig = {
|
||||
id: faker.random.alphaNumeric(32),
|
||||
};
|
||||
|
||||
const app = await getAppInfo(appInfo.id);
|
||||
fs.writeFileSync(`/runtipi/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
|
||||
|
||||
expect(app?.id).toEqual(appInfo.id);
|
||||
const app = await getAppInfo(appInfo.id, appEntity.status);
|
||||
|
||||
expect(app?.id).toEqual(newConfig.id);
|
||||
});
|
||||
|
||||
it('Should take config.json from repo if app is not installed', async () => {
|
||||
const { appInfo, MockFiles, appEntity } = await createApp({ installed: false });
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
const newConfig = {
|
||||
id: faker.random.alphaNumeric(32),
|
||||
available: true,
|
||||
};
|
||||
|
||||
fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
|
||||
|
||||
const app = await getAppInfo(appInfo.id, appEntity.status);
|
||||
|
||||
expect(app?.id).toEqual(newConfig.id);
|
||||
});
|
||||
|
||||
it('Should return null if app is not available', async () => {
|
||||
const { appInfo, MockFiles, appEntity } = await createApp({ installed: false });
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
const newConfig = {
|
||||
id: faker.random.alphaNumeric(32),
|
||||
available: false,
|
||||
};
|
||||
|
||||
fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
|
||||
|
||||
const app = await getAppInfo(appInfo.id, appEntity.status);
|
||||
|
||||
expect(app).toBeNull();
|
||||
});
|
||||
|
||||
it('Should throw if something goes wrong', async () => {
|
||||
const log = jest.spyOn(logger, 'error');
|
||||
const spy = jest.spyOn(fs, 'existsSync').mockImplementation(() => {
|
||||
throw new Error('Something went wrong');
|
||||
});
|
||||
|
||||
const { appInfo, MockFiles, appEntity } = await createApp({ installed: false });
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
const newConfig = {
|
||||
id: faker.random.alphaNumeric(32),
|
||||
available: false,
|
||||
};
|
||||
|
||||
fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
|
||||
|
||||
try {
|
||||
await getAppInfo(appInfo.id, appEntity.status);
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.message).toBe(`Error loading app: ${appInfo.id}`);
|
||||
expect(log).toBeCalledWith(`Error loading app: ${appInfo.id}`);
|
||||
}
|
||||
|
||||
spy.mockRestore();
|
||||
log.mockRestore();
|
||||
});
|
||||
|
||||
it('Should return null if app does not exist', async () => {
|
||||
|
|
|
@ -10,6 +10,7 @@ import { createUser } from '../../auth/__tests__/user.factory';
|
|||
import User from '../../auth/user.entity';
|
||||
import { installAppMutation, startAppMutation, stopAppMutation, uninstallAppMutation, updateAppConfigMutation, updateAppMutation } from '../../../test/mutations';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import EventDispatcher from '../../../core/config/EventDispatcher';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('child_process');
|
||||
|
@ -36,6 +37,7 @@ beforeEach(async () => {
|
|||
jest.resetModules();
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
|
||||
await App.clear();
|
||||
await User.clear();
|
||||
});
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import AppsService from '../apps.service';
|
||||
import fs from 'fs-extra';
|
||||
import config from '../../../config';
|
||||
import childProcess from 'child_process';
|
||||
import { AppInfo, AppStatusEnum } from '../apps.types';
|
||||
import App from '../app.entity';
|
||||
import { createApp } from './apps.factory';
|
||||
import { setupConnection, teardownConnection } from '../../../test/connection';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { getEnvMap } from '../apps.helpers';
|
||||
import EventDispatcher, { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('child_process');
|
||||
|
@ -23,6 +22,7 @@ beforeEach(async () => {
|
|||
jest.resetModules();
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
|
||||
await App.clear();
|
||||
});
|
||||
|
||||
|
@ -42,8 +42,9 @@ describe('Install app', () => {
|
|||
});
|
||||
|
||||
it('Should correctly generate env file for app', async () => {
|
||||
// EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
|
||||
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
|
||||
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`).toString();
|
||||
const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
|
||||
|
||||
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
|
||||
});
|
||||
|
@ -59,39 +60,28 @@ describe('Install app', () => {
|
|||
expect(app!.status).toBe(AppStatusEnum.RUNNING);
|
||||
});
|
||||
|
||||
it('Should correctly run app script', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
|
||||
|
||||
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should start app if already installed', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
|
||||
|
||||
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
|
||||
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
|
||||
|
||||
expect(spy.mock.calls.length).toBe(2);
|
||||
expect(spy.mock.calls[0]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
|
||||
expect(spy.mock.calls[1]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
|
||||
expect(spy.mock.calls[0]).toEqual([EventTypes.APP, ['install', app1.id]]);
|
||||
expect(spy.mock.calls[1]).toEqual([EventTypes.APP, ['start', app1.id]]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should delete app if install script fails', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
spy.mockImplementation(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
|
||||
|
||||
await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow('Test error');
|
||||
await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow(`App ${app1.id} failed to install\nstdout: error`);
|
||||
|
||||
const app = await App.findOne({ where: { id: app1.id } });
|
||||
|
||||
expect(app).toBeNull();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should throw if required form fields are missing', async () => {
|
||||
|
@ -112,7 +102,7 @@ describe('Install app', () => {
|
|||
|
||||
it('Should correctly copy app from repos to apps folder', async () => {
|
||||
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
|
||||
const appFolder = fs.readdirSync(`${config.ROOT_FOLDER}/apps/${app1.id}`);
|
||||
const appFolder = fs.readdirSync(`/runtipi/apps/${app1.id}`);
|
||||
|
||||
expect(appFolder).toBeDefined();
|
||||
expect(appFolder.indexOf('docker-compose.yml')).toBeGreaterThanOrEqual(0);
|
||||
|
@ -121,19 +111,19 @@ describe('Install app', () => {
|
|||
it('Should cleanup any app folder existing before install', async () => {
|
||||
const { MockFiles, appInfo } = await createApp({});
|
||||
app1 = appInfo;
|
||||
MockFiles[`/tipi/apps/${appInfo.id}/docker-compose.yml`] = 'test';
|
||||
MockFiles[`/tipi/apps/${appInfo.id}/test.yml`] = 'test';
|
||||
MockFiles[`/tipi/apps/${appInfo.id}`] = ['test.yml', 'docker-compose.yml'];
|
||||
MockFiles[`/runtipi/apps/${appInfo.id}/docker-compose.yml`] = 'test';
|
||||
MockFiles[`/runtipi/apps/${appInfo.id}/test.yml`] = 'test';
|
||||
MockFiles[`/runtipi/apps/${appInfo.id}`] = ['test.yml', 'docker-compose.yml'];
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/test.yml`)).toBe(true);
|
||||
expect(fs.existsSync(`/runtipi/apps/${app1.id}/test.yml`)).toBe(true);
|
||||
|
||||
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
|
||||
|
||||
expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/test.yml`)).toBe(false);
|
||||
expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/docker-compose.yml`)).toBe(true);
|
||||
expect(fs.existsSync(`/runtipi/apps/${app1.id}/test.yml`)).toBe(false);
|
||||
expect(fs.existsSync(`/runtipi/apps/${app1.id}/docker-compose.yml`)).toBe(true);
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and domain is not provided', async () => {
|
||||
|
@ -175,56 +165,51 @@ describe('Uninstall app', () => {
|
|||
});
|
||||
|
||||
it('App should be installed by default', async () => {
|
||||
// Act
|
||||
const app = await App.findOne({ where: { id: app1.id } });
|
||||
|
||||
// Assert
|
||||
expect(app).toBeDefined();
|
||||
expect(app!.id).toBe(app1.id);
|
||||
expect(app!.status).toBe(AppStatusEnum.RUNNING);
|
||||
});
|
||||
|
||||
it('Should correctly remove app from database', async () => {
|
||||
// Act
|
||||
await AppsService.uninstallApp(app1.id);
|
||||
|
||||
const app = await App.findOne({ where: { id: app1.id } });
|
||||
|
||||
// Assert
|
||||
expect(app).toBeNull();
|
||||
});
|
||||
|
||||
it('Should correctly run app script', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
|
||||
await AppsService.uninstallApp(app1.id);
|
||||
|
||||
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['uninstall', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should stop app if it is running', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
// Arrange
|
||||
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
|
||||
|
||||
// Act
|
||||
await AppsService.uninstallApp(app1.id);
|
||||
|
||||
// Assert
|
||||
expect(spy.mock.calls.length).toBe(2);
|
||||
expect(spy.mock.calls[0]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['stop', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
|
||||
expect(spy.mock.calls[1]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['uninstall', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
|
||||
expect(spy.mock.calls[0]).toEqual([EventTypes.APP, ['stop', app1.id]]);
|
||||
expect(spy.mock.calls[1]).toEqual([EventTypes.APP, ['uninstall', app1.id]]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should throw if app is not installed', async () => {
|
||||
// Act & Assert
|
||||
await expect(AppsService.uninstallApp('any')).rejects.toThrowError('App any not found');
|
||||
});
|
||||
|
||||
it('Should throw if uninstall script fails', async () => {
|
||||
// Update app
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
|
||||
await App.update({ id: app1.id }, { status: AppStatusEnum.UPDATING });
|
||||
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
spy.mockImplementation(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
|
||||
await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow('Test error');
|
||||
// Act & Assert
|
||||
await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to uninstall\nstdout: test`);
|
||||
const app = await App.findOne({ where: { id: app1.id } });
|
||||
expect(app!.status).toBe(AppStatusEnum.STOPPED);
|
||||
});
|
||||
|
@ -240,12 +225,12 @@ describe('Start app', () => {
|
|||
fs.__createMockFiles(Object.assign(app1create.MockFiles));
|
||||
});
|
||||
|
||||
it('Should correctly run app script', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
it('Should correctly dispatch event', async () => {
|
||||
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
|
||||
|
||||
await AppsService.startApp(app1.id);
|
||||
|
||||
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
|
||||
expect(spy.mock.lastCall).toEqual([EventTypes.APP, ['start', app1.id]]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
@ -255,7 +240,7 @@ describe('Start app', () => {
|
|||
});
|
||||
|
||||
it('Should restart if app is already running', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
|
||||
|
||||
await AppsService.startApp(app1.id);
|
||||
expect(spy.mock.calls.length).toBe(1);
|
||||
|
@ -266,22 +251,21 @@ describe('Start app', () => {
|
|||
});
|
||||
|
||||
it('Regenerate env file', async () => {
|
||||
fs.writeFile(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
|
||||
fs.writeFile(`/app/storage/app-data/${app1.id}/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
|
||||
|
||||
await AppsService.startApp(app1.id);
|
||||
|
||||
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`).toString();
|
||||
const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
|
||||
|
||||
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
|
||||
});
|
||||
|
||||
it('Should throw if start script fails', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
spy.mockImplementation(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
|
||||
|
||||
await expect(AppsService.startApp(app1.id)).rejects.toThrow('Test error');
|
||||
// Act & Assert
|
||||
await expect(AppsService.startApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to start\nstdout: test`);
|
||||
const app = await App.findOne({ where: { id: app1.id } });
|
||||
expect(app!.status).toBe(AppStatusEnum.STOPPED);
|
||||
});
|
||||
|
@ -297,12 +281,12 @@ describe('Stop app', () => {
|
|||
fs.__createMockFiles(Object.assign(app1create.MockFiles));
|
||||
});
|
||||
|
||||
it('Should correctly run app script', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
it('Should correctly dispatch stop event', async () => {
|
||||
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
|
||||
|
||||
await AppsService.stopApp(app1.id);
|
||||
|
||||
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['stop', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
|
||||
expect(spy.mock.lastCall).toEqual([EventTypes.APP, ['stop', app1.id]]);
|
||||
});
|
||||
|
||||
it('Should throw if app is not installed', async () => {
|
||||
|
@ -310,12 +294,11 @@ describe('Stop app', () => {
|
|||
});
|
||||
|
||||
it('Should throw if stop script fails', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
spy.mockImplementation(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
|
||||
|
||||
await expect(AppsService.stopApp(app1.id)).rejects.toThrow('Test error');
|
||||
// Act & Assert
|
||||
await expect(AppsService.stopApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to stop\nstdout: test`);
|
||||
const app = await App.findOne({ where: { id: app1.id } });
|
||||
expect(app!.status).toBe(AppStatusEnum.RUNNING);
|
||||
});
|
||||
|
@ -334,7 +317,7 @@ describe('Update app config', () => {
|
|||
it('Should correctly update app config', async () => {
|
||||
await AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' });
|
||||
|
||||
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`).toString();
|
||||
const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
|
||||
|
||||
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
|
||||
});
|
||||
|
@ -352,8 +335,8 @@ describe('Update app config', () => {
|
|||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`).toString();
|
||||
fs.writeFileSync(`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`, `${envFile}\nRANDOM_FIELD=test`);
|
||||
const envFile = fs.readFileSync(`/app/storage/app-data/${appInfo.id}/app.env`).toString();
|
||||
fs.writeFileSync(`/app/storage/app-data/${appInfo.id}/app.env`, `${envFile}\nRANDOM_FIELD=test`);
|
||||
|
||||
await AppsService.updateAppConfig(appInfo.id, { TEST_FIELD: 'test' });
|
||||
|
||||
|
@ -464,19 +447,19 @@ describe('Start all apps', () => {
|
|||
});
|
||||
|
||||
it('Should correctly start all apps', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
|
||||
|
||||
await AppsService.startAllApps();
|
||||
|
||||
expect(spy.mock.calls.length).toBe(2);
|
||||
expect(spy.mock.calls).toEqual([
|
||||
[`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)],
|
||||
[`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', app2.id, '/tipi', 'repo-id'], {}, expect.any(Function)],
|
||||
[EventTypes.APP, ['start', app1.id]],
|
||||
[EventTypes.APP, ['start', app2.id]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Should not start app which has not status RUNNING', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
|
||||
await createApp({ installed: true, status: AppStatusEnum.STOPPED });
|
||||
|
||||
await AppsService.startAllApps();
|
||||
|
@ -487,16 +470,14 @@ describe('Start all apps', () => {
|
|||
});
|
||||
|
||||
it('Should put app status to STOPPED if start script fails', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
spy.mockImplementation(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
|
||||
|
||||
// Act
|
||||
await AppsService.startAllApps();
|
||||
|
||||
const apps = await App.find();
|
||||
|
||||
expect(spy.mock.calls.length).toBe(2);
|
||||
// Assert
|
||||
expect(apps.length).toBe(2);
|
||||
expect(apps[0].status).toBe(AppStatusEnum.STOPPED);
|
||||
expect(apps[1].status).toBe(AppStatusEnum.STOPPED);
|
||||
|
@ -529,12 +510,10 @@ describe('Update app', () => {
|
|||
});
|
||||
|
||||
it('Should throw if update script fails', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
spy.mockImplementation(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
|
||||
|
||||
await expect(AppsService.updateApp(app1.id)).rejects.toThrow('Test error');
|
||||
await expect(AppsService.updateApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to update\nstdout: error`);
|
||||
const app = await App.findOne({ where: { id: app1.id } });
|
||||
expect(app!.status).toBe(AppStatusEnum.STOPPED);
|
||||
});
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
import portUsed from 'tcp-port-used';
|
||||
import { fileExists, getSeed, readdirSync, readFile, readJsonFile, runScript, writeFile } from '../fs/fs.helpers';
|
||||
import { fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../fs/fs.helpers';
|
||||
import InternalIp from 'internal-ip';
|
||||
import crypto from 'crypto';
|
||||
import config from '../../config';
|
||||
import { AppInfo, AppStatusEnum } from './apps.types';
|
||||
import logger from '../../config/logger/logger';
|
||||
import App from './app.entity';
|
||||
import { getConfig } from '../../core/config/TipiConfig';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
export const checkAppRequirements = async (appName: string) => {
|
||||
let valid = true;
|
||||
|
||||
const configFile: AppInfo | null = readJsonFile(`/repos/${config.APPS_REPO_ID}/apps/${appName}/config.json`);
|
||||
const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}/config.json`);
|
||||
|
||||
if (!configFile) {
|
||||
throw new Error(`App ${appName} not found`);
|
||||
|
@ -29,7 +30,7 @@ export const checkAppRequirements = async (appName: string) => {
|
|||
};
|
||||
|
||||
export const getEnvMap = (appName: string): Map<string, string> => {
|
||||
const envFile = readFile(`/app-data/${appName}/app.env`).toString();
|
||||
const envFile = readFile(`/app/storage/app-data/${appName}/app.env`).toString();
|
||||
const envVars = envFile.split('\n');
|
||||
const envVarsMap = new Map<string, string>();
|
||||
|
||||
|
@ -42,7 +43,7 @@ export const getEnvMap = (appName: string): Map<string, string> => {
|
|||
};
|
||||
|
||||
export const checkEnvFile = (appName: string) => {
|
||||
const configFile: AppInfo | null = readJsonFile(`/apps/${appName}/config.json`);
|
||||
const configFile: AppInfo | null = readJsonFile(`/runtipi/apps/${appName}/config.json`);
|
||||
const envMap = getEnvMap(appName);
|
||||
|
||||
configFile?.form_fields?.forEach((field) => {
|
||||
|
@ -55,19 +56,6 @@ export const checkEnvFile = (appName: string) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const runAppScript = async (params: string[]): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
runScript('/scripts/app.sh', [...params, config.ROOT_FOLDER_HOST, config.APPS_REPO_ID], (err: string) => {
|
||||
if (err) {
|
||||
logger.error(err);
|
||||
reject(err);
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getEntropy = (name: string, length: number) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(name + getSeed());
|
||||
|
@ -75,13 +63,13 @@ const getEntropy = (name: string, length: number) => {
|
|||
};
|
||||
|
||||
export const generateEnvFile = (app: App) => {
|
||||
const configFile: AppInfo | null = readJsonFile(`/apps/${app.id}/config.json`);
|
||||
const configFile: AppInfo | null = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
|
||||
|
||||
if (!configFile) {
|
||||
throw new Error(`App ${app.id} not found`);
|
||||
}
|
||||
|
||||
const baseEnvFile = readFile('/.env').toString();
|
||||
const baseEnvFile = readFile('/runtipi/.env').toString();
|
||||
let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
|
||||
const envMap = getEnvMap(app.id);
|
||||
|
||||
|
@ -110,20 +98,25 @@ export const generateEnvFile = (app: App) => {
|
|||
envFile += `APP_DOMAIN=${app.domain}\n`;
|
||||
envFile += 'APP_PROTOCOL=https\n';
|
||||
} else {
|
||||
envFile += `APP_DOMAIN=${config.INTERNAL_IP}:${configFile.port}\n`;
|
||||
envFile += `APP_DOMAIN=${getConfig().internalIp}:${configFile.port}\n`;
|
||||
}
|
||||
|
||||
writeFile(`/app-data/${app.id}/app.env`, envFile);
|
||||
// Create app-data folder if it doesn't exist
|
||||
if (!fs.existsSync(`/app/storage/app-data/${app.id}`)) {
|
||||
fs.mkdirSync(`/app/storage/app-data/${app.id}`, { recursive: true });
|
||||
}
|
||||
|
||||
writeFile(`/app/storage/app-data/${app.id}/app.env`, envFile);
|
||||
};
|
||||
|
||||
export const getAvailableApps = async (): Promise<string[]> => {
|
||||
const apps: string[] = [];
|
||||
|
||||
const appsDir = readdirSync(`/repos/${config.APPS_REPO_ID}/apps`);
|
||||
const appsDir = readdirSync(`/runtipi/repos/${getConfig().appsRepoId}/apps`);
|
||||
|
||||
appsDir.forEach((app) => {
|
||||
if (fileExists(`/repos/${config.APPS_REPO_ID}/apps/${app}/config.json`)) {
|
||||
const configFile: AppInfo = readJsonFile(`/repos/${config.APPS_REPO_ID}/apps/${app}/config.json`);
|
||||
if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`)) {
|
||||
const configFile: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
|
||||
|
||||
if (configFile.available) {
|
||||
apps.push(app);
|
||||
|
@ -136,18 +129,16 @@ export const getAvailableApps = async (): Promise<string[]> => {
|
|||
|
||||
export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null => {
|
||||
try {
|
||||
const repoId = config.APPS_REPO_ID;
|
||||
|
||||
// Check if app is installed
|
||||
const installed = typeof status !== 'undefined' && status !== AppStatusEnum.MISSING;
|
||||
|
||||
if (installed && fileExists(`/apps/${id}/config.json`)) {
|
||||
const configFile: AppInfo = readJsonFile(`/apps/${id}/config.json`);
|
||||
configFile.description = readFile(`/apps/${id}/metadata/description.md`).toString();
|
||||
if (installed && fileExists(`/runtipi/apps/${id}/config.json`)) {
|
||||
const configFile: AppInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
|
||||
configFile.description = readFile(`/runtipi/apps/${id}/metadata/description.md`).toString();
|
||||
return configFile;
|
||||
} else if (fileExists(`/repos/${repoId}/apps/${id}/config.json`)) {
|
||||
const configFile: AppInfo = readJsonFile(`/repos/${repoId}/apps/${id}/config.json`);
|
||||
configFile.description = readFile(`/repos/${repoId}/apps/${id}/metadata/description.md`);
|
||||
} else if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
|
||||
const configFile: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
|
||||
configFile.description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/metadata/description.md`);
|
||||
|
||||
if (configFile.available) {
|
||||
return configFile;
|
||||
|
@ -156,21 +147,21 @@ export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null =
|
|||
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error(`Error loading app ${id}`);
|
||||
logger.error(`Error loading app: ${id}`);
|
||||
throw new Error(`Error loading app: ${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const getUpdateInfo = async (id: string) => {
|
||||
const app = await App.findOne({ where: { id } });
|
||||
|
||||
const doesFileExist = fileExists(`/repos/${config.APPS_REPO_ID}/apps/${id}`);
|
||||
const doesFileExist = fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}`);
|
||||
|
||||
if (!app || !doesFileExist) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repoConfig: AppInfo = readJsonFile(`/repos/${config.APPS_REPO_ID}/apps/${id}/config.json`);
|
||||
const repoConfig: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
|
||||
|
||||
return {
|
||||
current: app.version,
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
import validator from 'validator';
|
||||
import { createFolder, ensureAppFolder, readFile, readJsonFile } from '../fs/fs.helpers';
|
||||
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, runAppScript } from './apps.helpers';
|
||||
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps } from './apps.helpers';
|
||||
import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
|
||||
import App from './app.entity';
|
||||
import logger from '../../config/logger/logger';
|
||||
import config from '../../config';
|
||||
import { Not } from 'typeorm';
|
||||
import { getConfig } from '../../core/config/TipiConfig';
|
||||
import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher';
|
||||
|
||||
const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
|
||||
|
||||
/**
|
||||
* Start all apps which had the status RUNNING in the database
|
||||
*/
|
||||
const startAllApps = async (): Promise<void> => {
|
||||
const apps = await App.find({ where: { status: AppStatusEnum.RUNNING } });
|
||||
|
||||
|
@ -22,8 +26,13 @@ const startAllApps = async (): Promise<void> => {
|
|||
|
||||
await App.update({ id: app.id }, { status: AppStatusEnum.STARTING });
|
||||
|
||||
await runAppScript(['start', app.id]);
|
||||
await App.update({ id: app.id }, { status: AppStatusEnum.RUNNING });
|
||||
eventDispatcher.dispatchEventAsync(EventTypes.APP, ['start', app.id]).then(({ success }) => {
|
||||
if (success) {
|
||||
App.update({ id: app.id }, { status: AppStatusEnum.RUNNING });
|
||||
} else {
|
||||
App.update({ id: app.id }, { status: AppStatusEnum.STOPPED });
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
await App.update({ id: app.id }, { status: AppStatusEnum.STOPPED });
|
||||
logger.error(e);
|
||||
|
@ -32,6 +41,11 @@ const startAllApps = async (): Promise<void> => {
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Start an app
|
||||
* @param appName - id of the app to start
|
||||
* @returns - the app entity
|
||||
*/
|
||||
const startApp = async (appName: string): Promise<App> => {
|
||||
let app = await App.findOne({ where: { id: appName } });
|
||||
|
||||
|
@ -40,20 +54,18 @@ const startApp = async (appName: string): Promise<App> => {
|
|||
}
|
||||
|
||||
ensureAppFolder(appName);
|
||||
|
||||
// Regenerate env file
|
||||
generateEnvFile(app);
|
||||
|
||||
checkEnvFile(appName);
|
||||
|
||||
await App.update({ id: appName }, { status: AppStatusEnum.STARTING });
|
||||
// Run script
|
||||
try {
|
||||
await runAppScript(['start', appName]);
|
||||
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['start', app.id]);
|
||||
|
||||
if (success) {
|
||||
await App.update({ id: appName }, { status: AppStatusEnum.RUNNING });
|
||||
} catch (e) {
|
||||
} else {
|
||||
await App.update({ id: appName }, { status: AppStatusEnum.STOPPED });
|
||||
throw e;
|
||||
throw new Error(`App ${appName} failed to start\nstdout: ${stdout}`);
|
||||
}
|
||||
|
||||
app = (await App.findOne({ where: { id: appName } })) as App;
|
||||
|
@ -61,6 +73,14 @@ const startApp = async (appName: string): Promise<App> => {
|
|||
return app;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given parameters, create a new app and start it
|
||||
* @param id - id of the app to stop
|
||||
* @param form - form data
|
||||
* @param exposed - if the app should be exposed
|
||||
* @param domain - domain to expose the app on
|
||||
* @returns - the app entity
|
||||
*/
|
||||
const installApp = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
|
||||
let app = await App.findOne({ where: { id } });
|
||||
|
||||
|
@ -83,9 +103,9 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
|
|||
}
|
||||
|
||||
// Create app folder
|
||||
createFolder(`/app-data/${id}`);
|
||||
createFolder(`/app/storage/app-data/${id}`);
|
||||
|
||||
const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
|
||||
const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
|
||||
|
||||
if (!appInfo?.exposable && exposed) {
|
||||
throw new Error(`App ${id} is not exposable`);
|
||||
|
@ -104,11 +124,11 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
|
|||
generateEnvFile(app);
|
||||
|
||||
// Run script
|
||||
try {
|
||||
await runAppScript(['install', id]);
|
||||
} catch (e) {
|
||||
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['install', id]);
|
||||
|
||||
if (!success) {
|
||||
await App.delete({ id });
|
||||
throw e;
|
||||
throw new Error(`App ${id} failed to install\nstdout: ${stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -118,13 +138,17 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
|
|||
return app;
|
||||
};
|
||||
|
||||
/**
|
||||
* List all apps available for installation
|
||||
* @returns - list of all apps available
|
||||
*/
|
||||
const listApps = async (): Promise<ListAppsResonse> => {
|
||||
const folders: string[] = await getAvailableApps();
|
||||
|
||||
const apps: AppInfo[] = folders
|
||||
.map((app) => {
|
||||
try {
|
||||
return readJsonFile(`/repos/${config.APPS_REPO_ID}/apps/${app}/config.json`);
|
||||
return readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
@ -132,12 +156,20 @@ const listApps = async (): Promise<ListAppsResonse> => {
|
|||
.filter(Boolean);
|
||||
|
||||
apps.forEach((app) => {
|
||||
app.description = readFile(`/repos/${config.APPS_REPO_ID}/apps/${app.id}/metadata/description.md`);
|
||||
app.description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app.id}/metadata/description.md`);
|
||||
});
|
||||
|
||||
return { apps: apps.sort(sortApps), total: apps.length };
|
||||
};
|
||||
|
||||
/**
|
||||
* Given parameters, updates an app config and regenerates the env file
|
||||
* @param id - id of the app to stop
|
||||
* @param form - form data
|
||||
* @param exposed - if the app should be exposed
|
||||
* @param domain - domain to expose the app on
|
||||
* @returns - the app entity
|
||||
*/
|
||||
const updateAppConfig = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
|
||||
if (exposed && !domain) {
|
||||
throw new Error('Domain is required if app is exposed');
|
||||
|
@ -147,7 +179,7 @@ const updateAppConfig = async (id: string, form: Record<string, string>, exposed
|
|||
throw new Error(`Domain ${domain} is not valid`);
|
||||
}
|
||||
|
||||
const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
|
||||
const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
|
||||
|
||||
if (!appInfo?.exposable && exposed) {
|
||||
throw new Error(`App ${id} is not exposable`);
|
||||
|
@ -175,6 +207,11 @@ const updateAppConfig = async (id: string, form: Record<string, string>, exposed
|
|||
return app;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops an app
|
||||
* @param id - id of the app to stop
|
||||
* @returns - the app entity
|
||||
*/
|
||||
const stopApp = async (id: string): Promise<App> => {
|
||||
let app = await App.findOne({ where: { id } });
|
||||
|
||||
|
@ -183,16 +220,18 @@ const stopApp = async (id: string): Promise<App> => {
|
|||
}
|
||||
|
||||
ensureAppFolder(id);
|
||||
generateEnvFile(app);
|
||||
|
||||
// Run script
|
||||
await App.update({ id }, { status: AppStatusEnum.STOPPING });
|
||||
|
||||
try {
|
||||
await runAppScript(['stop', id]);
|
||||
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['stop', id]);
|
||||
|
||||
if (success) {
|
||||
await App.update({ id }, { status: AppStatusEnum.STOPPED });
|
||||
} catch (e) {
|
||||
} else {
|
||||
await App.update({ id }, { status: AppStatusEnum.RUNNING });
|
||||
throw e;
|
||||
throw new Error(`App ${id} failed to stop\nstdout: ${stdout}`);
|
||||
}
|
||||
|
||||
app = (await App.findOne({ where: { id } })) as App;
|
||||
|
@ -200,6 +239,11 @@ const stopApp = async (id: string): Promise<App> => {
|
|||
return app;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uninstalls an app
|
||||
* @param id - id of the app to uninstall
|
||||
* @returns - the app entity
|
||||
*/
|
||||
const uninstallApp = async (id: string): Promise<App> => {
|
||||
let app = await App.findOne({ where: { id } });
|
||||
|
||||
|
@ -211,14 +255,15 @@ const uninstallApp = async (id: string): Promise<App> => {
|
|||
}
|
||||
|
||||
ensureAppFolder(id);
|
||||
generateEnvFile(app);
|
||||
|
||||
await App.update({ id }, { status: AppStatusEnum.UNINSTALLING });
|
||||
// Run script
|
||||
try {
|
||||
await runAppScript(['uninstall', id]);
|
||||
} catch (e) {
|
||||
|
||||
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['uninstall', id]);
|
||||
|
||||
if (!success) {
|
||||
await App.update({ id }, { status: AppStatusEnum.STOPPED });
|
||||
throw e;
|
||||
throw new Error(`App ${id} failed to uninstall\nstdout: ${stdout}`);
|
||||
}
|
||||
|
||||
await App.delete({ id });
|
||||
|
@ -226,6 +271,11 @@ const uninstallApp = async (id: string): Promise<App> => {
|
|||
return { id, status: AppStatusEnum.MISSING, config: {} } as App;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an app entity
|
||||
* @param id - id of the app
|
||||
* @returns - the app entity
|
||||
*/
|
||||
const getApp = async (id: string): Promise<App> => {
|
||||
let app = await App.findOne({ where: { id } });
|
||||
|
||||
|
@ -236,6 +286,11 @@ const getApp = async (id: string): Promise<App> => {
|
|||
return app;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an app to the latest version from repository
|
||||
* @param id - id of the app
|
||||
* @returns - the app entity
|
||||
*/
|
||||
const updateApp = async (id: string) => {
|
||||
let app = await App.findOne({ where: { id } });
|
||||
|
||||
|
@ -244,21 +299,21 @@ const updateApp = async (id: string) => {
|
|||
}
|
||||
|
||||
ensureAppFolder(id);
|
||||
generateEnvFile(app);
|
||||
|
||||
await App.update({ id }, { status: AppStatusEnum.UPDATING });
|
||||
|
||||
// Run script
|
||||
try {
|
||||
await runAppScript(['update', id]);
|
||||
const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
|
||||
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['update', id]);
|
||||
|
||||
if (success) {
|
||||
const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
|
||||
await App.update({ id }, { status: AppStatusEnum.RUNNING, version: Number(appInfo?.tipi_version) });
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
throw e;
|
||||
} finally {
|
||||
} else {
|
||||
await App.update({ id }, { status: AppStatusEnum.STOPPED });
|
||||
throw new Error(`App ${id} failed to update\nstdout: ${stdout}`);
|
||||
}
|
||||
|
||||
await App.update({ id }, { status: AppStatusEnum.STOPPED });
|
||||
app = (await App.findOne({ where: { id } })) as App;
|
||||
|
||||
return app;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import childProcess from 'child_process';
|
||||
import config from '../../../config';
|
||||
import { getAbsolutePath, readJsonFile, readFile, readdirSync, fileExists, writeFile, createFolder, deleteFolder, runScript, getSeed, ensureAppFolder } from '../fs.helpers';
|
||||
import { readJsonFile, readFile, readdirSync, fileExists, writeFile, createFolder, deleteFolder, getSeed, ensureAppFolder } from '../fs.helpers';
|
||||
import fs from 'fs-extra';
|
||||
import { getConfig } from '../../../core/config/TipiConfig';
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
|
@ -10,24 +10,18 @@ beforeEach(() => {
|
|||
fs.__resetAllMocks();
|
||||
});
|
||||
|
||||
describe('Test: getAbsolutePath', () => {
|
||||
it('should return the absolute path', () => {
|
||||
expect(getAbsolutePath('/test')).toBe(`${config.ROOT_FOLDER}/test`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: readJsonFile', () => {
|
||||
it('should return the json file', () => {
|
||||
// Arrange
|
||||
const rawFile = '{"test": "test"}';
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/test-file.json`]: rawFile,
|
||||
['/runtipi/test-file.json']: rawFile,
|
||||
};
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
||||
// Act
|
||||
const file = readJsonFile('/test-file.json');
|
||||
const file = readJsonFile('/runtipi/test-file.json');
|
||||
|
||||
// Assert
|
||||
expect(file).toEqual({ test: 'test' });
|
||||
|
@ -36,19 +30,35 @@ describe('Test: readJsonFile', () => {
|
|||
it('should return null if the file does not exist', () => {
|
||||
expect(readJsonFile('/test')).toBeNull();
|
||||
});
|
||||
|
||||
it('Should return null if fs.readFile throws an error', () => {
|
||||
// Arrange
|
||||
// @ts-ignore
|
||||
const spy = jest.spyOn(fs, 'readFileSync');
|
||||
spy.mockImplementation(() => {
|
||||
throw new Error('Error');
|
||||
});
|
||||
|
||||
// Act
|
||||
const file = readJsonFile('/test');
|
||||
|
||||
// Assert
|
||||
expect(file).toBeNull();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: readFile', () => {
|
||||
it('should return the file', () => {
|
||||
const rawFile = 'test';
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/test-file.txt`]: rawFile,
|
||||
['/runtipi/test-file.txt']: rawFile,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
||||
expect(readFile('/test-file.txt')).toEqual('test');
|
||||
expect(readFile('/runtipi/test-file.txt')).toEqual('test');
|
||||
});
|
||||
|
||||
it('should return empty string if the file does not exist', () => {
|
||||
|
@ -59,13 +69,13 @@ describe('Test: readFile', () => {
|
|||
describe('Test: readdirSync', () => {
|
||||
it('should return the files', () => {
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/test/test-file.txt`]: 'test',
|
||||
['/runtipi/test/test-file.txt']: 'test',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
||||
expect(readdirSync('/test')).toEqual(['test-file.txt']);
|
||||
expect(readdirSync('/runtipi/test')).toEqual(['test-file.txt']);
|
||||
});
|
||||
|
||||
it('should return empty array if the directory does not exist', () => {
|
||||
|
@ -76,13 +86,13 @@ describe('Test: readdirSync', () => {
|
|||
describe('Test: fileExists', () => {
|
||||
it('should return true if the file exists', () => {
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/test-file.txt`]: 'test',
|
||||
['/runtipi/test-file.txt']: 'test',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
||||
expect(fileExists('/test-file.txt')).toBeTruthy();
|
||||
expect(fileExists('/runtipi/test-file.txt')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false if the file does not exist', () => {
|
||||
|
@ -94,9 +104,9 @@ describe('Test: writeFile', () => {
|
|||
it('should write the file', () => {
|
||||
const spy = jest.spyOn(fs, 'writeFileSync');
|
||||
|
||||
writeFile('/test-file.txt', 'test');
|
||||
writeFile('/runtipi/test-file.txt', 'test');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test-file.txt`, 'test');
|
||||
expect(spy).toHaveBeenCalledWith('/runtipi/test-file.txt', 'test');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -106,7 +116,7 @@ describe('Test: createFolder', () => {
|
|||
|
||||
createFolder('/test');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test`);
|
||||
expect(spy).toHaveBeenCalledWith('/test', { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -116,25 +126,14 @@ describe('Test: deleteFolder', () => {
|
|||
|
||||
deleteFolder('/test');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test`, { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: runScript', () => {
|
||||
it('should run the script', () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
const callback = jest.fn();
|
||||
|
||||
runScript('/test', [], callback);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test`, [], {}, callback);
|
||||
expect(spy).toHaveBeenCalledWith('/test', { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: getSeed', () => {
|
||||
it('should return the seed', () => {
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/state/seed`]: 'test',
|
||||
['/runtipi/state/seed']: 'test',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
|
@ -147,7 +146,7 @@ describe('Test: getSeed', () => {
|
|||
describe('Test: ensureAppFolder', () => {
|
||||
beforeEach(() => {
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}/apps/test`]: ['test.yml'],
|
||||
[`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
|
||||
};
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
@ -158,15 +157,15 @@ describe('Test: ensureAppFolder', () => {
|
|||
ensureAppFolder('test');
|
||||
|
||||
// Assert
|
||||
const files = fs.readdirSync(`${config.ROOT_FOLDER}/apps/test`);
|
||||
const files = fs.readdirSync('/runtipi/apps/test');
|
||||
expect(files).toEqual(['test.yml']);
|
||||
});
|
||||
|
||||
it('should not copy the folder if it already exists', () => {
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}/apps/test`]: ['test.yml'],
|
||||
[`${config.ROOT_FOLDER}/apps/test`]: ['docker-compose.yml'],
|
||||
[`${config.ROOT_FOLDER}/apps/test/docker-compose.yml`]: 'test',
|
||||
[`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
|
||||
['/runtipi/apps/test']: ['docker-compose.yml'],
|
||||
['/runtipi/apps/test/docker-compose.yml']: 'test',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
|
@ -176,15 +175,15 @@ describe('Test: ensureAppFolder', () => {
|
|||
ensureAppFolder('test');
|
||||
|
||||
// Assert
|
||||
const files = fs.readdirSync(`${config.ROOT_FOLDER}/apps/test`);
|
||||
const files = fs.readdirSync('/runtipi/apps/test');
|
||||
expect(files).toEqual(['docker-compose.yml']);
|
||||
});
|
||||
|
||||
it('Should overwrite the folder if clean up is true', () => {
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}/apps/test`]: ['test.yml'],
|
||||
[`${config.ROOT_FOLDER}/apps/test`]: ['docker-compose.yml'],
|
||||
[`${config.ROOT_FOLDER}/apps/test/docker-compose.yml`]: 'test',
|
||||
[`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
|
||||
['/runtipi/apps/test']: ['docker-compose.yml'],
|
||||
['/runtipi/apps/test/docker-compose.yml']: 'test',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
|
@ -194,7 +193,26 @@ describe('Test: ensureAppFolder', () => {
|
|||
ensureAppFolder('test', true);
|
||||
|
||||
// Assert
|
||||
const files = fs.readdirSync(`${config.ROOT_FOLDER}/apps/test`);
|
||||
const files = fs.readdirSync('/runtipi/apps/test');
|
||||
expect(files).toEqual(['test.yml']);
|
||||
});
|
||||
|
||||
it('Should delete folder if it exists but has no docker-compose.yml file', () => {
|
||||
// Arrange
|
||||
const randomFileName = `${faker.random.word()}.yml`;
|
||||
const mockFiles = {
|
||||
[`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: [randomFileName],
|
||||
['/runtipi/apps/test']: ['test.yml'],
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
||||
// Act
|
||||
ensureAppFolder('test');
|
||||
|
||||
// Assert
|
||||
const files = fs.readdirSync('/runtipi/apps/test');
|
||||
expect(files).toEqual([randomFileName]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,55 +1,52 @@
|
|||
import fs from 'fs-extra';
|
||||
import childProcess from 'child_process';
|
||||
import config from '../../config';
|
||||
|
||||
export const getAbsolutePath = (path: string) => `${config.ROOT_FOLDER}${path}`;
|
||||
import { getConfig } from '../../core/config/TipiConfig';
|
||||
|
||||
export const readJsonFile = (path: string): any => {
|
||||
const rawFile = fs.readFileSync(getAbsolutePath(path))?.toString();
|
||||
try {
|
||||
const rawFile = fs.readFileSync(path).toString();
|
||||
|
||||
if (!rawFile) {
|
||||
return JSON.parse(rawFile);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(rawFile);
|
||||
};
|
||||
|
||||
export const readFile = (path: string): string => {
|
||||
try {
|
||||
return fs.readFileSync(getAbsolutePath(path)).toString();
|
||||
return fs.readFileSync(path).toString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const readdirSync = (path: string): string[] => fs.readdirSync(getAbsolutePath(path));
|
||||
export const readdirSync = (path: string): string[] => fs.readdirSync(path);
|
||||
|
||||
export const fileExists = (path: string): boolean => fs.existsSync(getAbsolutePath(path));
|
||||
export const fileExists = (path: string): boolean => fs.existsSync(path);
|
||||
|
||||
export const writeFile = (path: string, data: any) => fs.writeFileSync(getAbsolutePath(path), data);
|
||||
export const writeFile = (path: string, data: any) => fs.writeFileSync(path, data);
|
||||
|
||||
export const createFolder = (path: string) => {
|
||||
if (!fileExists(path)) {
|
||||
fs.mkdirSync(getAbsolutePath(path));
|
||||
fs.mkdirSync(path, { recursive: true });
|
||||
}
|
||||
};
|
||||
export const deleteFolder = (path: string) => fs.rmSync(getAbsolutePath(path), { recursive: true });
|
||||
|
||||
export const runScript = (path: string, args: string[], callback?: any) => childProcess.execFile(getAbsolutePath(path), args, {}, callback);
|
||||
export const deleteFolder = (path: string) => fs.rmSync(path, { recursive: true });
|
||||
|
||||
export const getSeed = () => {
|
||||
const seed = readFile('/state/seed');
|
||||
const seed = readFile('/runtipi/state/seed');
|
||||
return seed.toString();
|
||||
};
|
||||
|
||||
export const ensureAppFolder = (appName: string, cleanup = false) => {
|
||||
if (cleanup && fileExists(`/apps/${appName}`)) {
|
||||
deleteFolder(`/apps/${appName}`);
|
||||
if (cleanup && fileExists(`/runtipi/apps/${appName}`)) {
|
||||
deleteFolder(`/runtipi/apps/${appName}`);
|
||||
}
|
||||
|
||||
if (!fileExists(`/apps/${appName}/docker-compose.yml`)) {
|
||||
if (fileExists(`/apps/${appName}`)) deleteFolder(`/apps/${appName}`);
|
||||
if (!fileExists(`/runtipi/apps/${appName}/docker-compose.yml`)) {
|
||||
if (fileExists(`/runtipi/apps/${appName}`)) {
|
||||
deleteFolder(`/runtipi/apps/${appName}`);
|
||||
}
|
||||
// Copy from apps repo
|
||||
fs.copySync(getAbsolutePath(`/repos/${config.APPS_REPO_ID}/apps/${appName}`), getAbsolutePath(`/apps/${appName}`));
|
||||
fs.copySync(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}`, `/runtipi/apps/${appName}`);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,242 @@
|
|||
import { faker } from '@faker-js/faker';
|
||||
import axios from 'axios';
|
||||
import fs from 'fs-extra';
|
||||
import { DataSource } from 'typeorm';
|
||||
import TipiCache from '../../../config/TipiCache';
|
||||
import * as TipiConfig from '../../../core/config/TipiConfig';
|
||||
import { setConfig } from '../../../core/config/TipiConfig';
|
||||
import { setupConnection, teardownConnection } from '../../../test/connection';
|
||||
import { gcall } from '../../../test/gcall';
|
||||
import { restartMutation, updateMutation } from '../../../test/mutations';
|
||||
import { systemInfoQuery, versionQuery } from '../../../test/queries';
|
||||
import User from '../../auth/user.entity';
|
||||
import { createUser } from '../../auth/__tests__/user.factory';
|
||||
import { SystemInfoResponse } from '../system.types';
|
||||
import EventDispatcher from '../../../core/config/EventDispatcher';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('axios');
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
let db: DataSource | null = null;
|
||||
const TEST_SUITE = 'systemresolver';
|
||||
beforeAll(async () => {
|
||||
db = await setupConnection(TEST_SUITE);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.destroy();
|
||||
await teardownConnection(TEST_SUITE);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
await User.clear();
|
||||
});
|
||||
|
||||
describe('Test: systemInfo', () => {
|
||||
beforeEach(async () => {});
|
||||
|
||||
it('Should return correct system info from file', async () => {
|
||||
const systemInfo = {
|
||||
cpu: { load: 10 },
|
||||
memory: { available: 100, total: 1000, used: 900 },
|
||||
disk: { available: 100, total: 1000, used: 900 },
|
||||
};
|
||||
|
||||
const MockFiles = {
|
||||
'/runtipi/state/system-info.json': JSON.stringify(systemInfo),
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
const { data } = await gcall<{ systemInfo: SystemInfoResponse }>({ source: systemInfoQuery });
|
||||
|
||||
expect(data?.systemInfo).toBeDefined();
|
||||
expect(data?.systemInfo.cpu).toBeDefined();
|
||||
expect(data?.systemInfo.cpu.load).toBe(systemInfo.cpu.load);
|
||||
expect(data?.systemInfo.memory).toBeDefined();
|
||||
expect(data?.systemInfo.memory.available).toBe(systemInfo.memory.available);
|
||||
expect(data?.systemInfo.memory.total).toBe(systemInfo.memory.total);
|
||||
expect(data?.systemInfo.memory.used).toBe(systemInfo.memory.used);
|
||||
expect(data?.systemInfo.disk).toBeDefined();
|
||||
expect(data?.systemInfo.disk.available).toBe(systemInfo.disk.available);
|
||||
expect(data?.systemInfo.disk.total).toBe(systemInfo.disk.total);
|
||||
expect(data?.systemInfo.disk.used).toBe(systemInfo.disk.used);
|
||||
});
|
||||
|
||||
it('Should return 0 for missing values', async () => {
|
||||
const systemInfo = {
|
||||
cpu: {},
|
||||
memory: {},
|
||||
disk: {},
|
||||
};
|
||||
|
||||
const MockFiles = {
|
||||
'/runtipi/state/system-info.json': JSON.stringify(systemInfo),
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
const { data } = await gcall<{ systemInfo: SystemInfoResponse }>({ source: systemInfoQuery });
|
||||
|
||||
expect(data?.systemInfo).toBeDefined();
|
||||
expect(data?.systemInfo.cpu).toBeDefined();
|
||||
expect(data?.systemInfo.cpu.load).toBe(0);
|
||||
expect(data?.systemInfo.memory).toBeDefined();
|
||||
expect(data?.systemInfo.memory.available).toBe(0);
|
||||
expect(data?.systemInfo.memory.total).toBe(0);
|
||||
expect(data?.systemInfo.memory.used).toBe(0);
|
||||
expect(data?.systemInfo.disk).toBeDefined();
|
||||
expect(data?.systemInfo.disk.available).toBe(0);
|
||||
expect(data?.systemInfo.disk.total).toBe(0);
|
||||
expect(data?.systemInfo.disk.used).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: getVersion', () => {
|
||||
const current = `${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}`;
|
||||
const latest = `${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}`;
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(axios, 'get').mockResolvedValue({
|
||||
data: { name: `v${latest}` },
|
||||
});
|
||||
setConfig('version', current);
|
||||
});
|
||||
|
||||
it('Should return correct version', async () => {
|
||||
const { data } = await gcall<{ version: { current: string; latest?: string } }>({
|
||||
source: versionQuery,
|
||||
});
|
||||
|
||||
expect(data?.version).toBeDefined();
|
||||
expect(data?.version.current).toBeDefined();
|
||||
expect(data?.version.latest).toBeDefined();
|
||||
expect(data?.version.current).toBe(current);
|
||||
expect(data?.version.latest).toBe(latest);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: restart', () => {
|
||||
beforeEach(async () => {
|
||||
setConfig('status', 'RUNNING');
|
||||
setConfig('version', '1.0.0');
|
||||
TipiCache.set('latestVersion', '1.0.1');
|
||||
});
|
||||
|
||||
it('Should return true', async () => {
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
|
||||
|
||||
// Act
|
||||
const user = await createUser();
|
||||
const { data } = await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id });
|
||||
|
||||
// Assert
|
||||
expect(data?.restart).toBeDefined();
|
||||
expect(data?.restart).toBe(true);
|
||||
});
|
||||
|
||||
it("Should return an error if user doesn't exist", async () => {
|
||||
// Arrange
|
||||
const { data, errors } = await gcall<{ restart: boolean }>({
|
||||
source: restartMutation,
|
||||
userId: 1,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
|
||||
expect(data?.restart).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should throw an error if no userId is not provided', async () => {
|
||||
// Arrange
|
||||
const { data, errors } = await gcall<{ restart: boolean }>({ source: restartMutation });
|
||||
|
||||
// Assert
|
||||
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
|
||||
expect(data?.restart).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should set app status to restarting', async () => {
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
|
||||
const spy = jest.spyOn(TipiConfig, 'setConfig');
|
||||
const user = await createUser();
|
||||
|
||||
// Act
|
||||
await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id });
|
||||
|
||||
// Assert
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
expect(spy).toHaveBeenNthCalledWith(1, 'status', 'RESTARTING');
|
||||
expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING');
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: update', () => {
|
||||
beforeEach(async () => {
|
||||
setConfig('status', 'RUNNING');
|
||||
setConfig('version', '1.0.0');
|
||||
TipiCache.set('latestVersion', '1.0.1');
|
||||
});
|
||||
|
||||
it('Should return true', async () => {
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
|
||||
const user = await createUser();
|
||||
|
||||
// Act
|
||||
const { data } = await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id });
|
||||
|
||||
// Assert
|
||||
expect(data?.update).toBeDefined();
|
||||
expect(data?.update).toBe(true);
|
||||
});
|
||||
|
||||
it("Should return an error if user doesn't exist", async () => {
|
||||
// Act
|
||||
const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation, userId: 1 });
|
||||
|
||||
// Assert
|
||||
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
|
||||
expect(data?.update).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should throw an error if no userId is not provided', async () => {
|
||||
// Act
|
||||
const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation });
|
||||
|
||||
// Assert
|
||||
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
|
||||
expect(data?.update).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should set app status to updating', async () => {
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
|
||||
const spy = jest.spyOn(TipiConfig, 'setConfig');
|
||||
const user = await createUser();
|
||||
|
||||
// Act
|
||||
await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id });
|
||||
|
||||
// Assert
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
expect(spy).toHaveBeenNthCalledWith(1, 'status', 'UPDATING');
|
||||
expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING');
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,213 @@
|
|||
import fs from 'fs-extra';
|
||||
import semver from 'semver';
|
||||
import axios from 'axios';
|
||||
import SystemService from '../system.service';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import TipiCache from '../../../config/TipiCache';
|
||||
import { setConfig } from '../../../core/config/TipiConfig';
|
||||
import logger from '../../../config/logger/logger';
|
||||
import EventDispatcher from '../../../core/config/EventDispatcher';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('axios');
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('Test: systemInfo', () => {
|
||||
it('Should throw if system-info.json does not exist', () => {
|
||||
try {
|
||||
SystemService.systemInfo();
|
||||
} catch (e: any) {
|
||||
expect(e).toBeDefined();
|
||||
expect(e.message).toBe('Error parsing system info');
|
||||
}
|
||||
});
|
||||
|
||||
it('It should return system info', async () => {
|
||||
// Arrange
|
||||
const info = {
|
||||
cpu: { load: 0.1 },
|
||||
memory: { available: 1000, total: 2000, used: 1000 },
|
||||
disk: { available: 1000, total: 2000, used: 1000 },
|
||||
};
|
||||
|
||||
const MockFiles = {
|
||||
'/runtipi/state/system-info.json': JSON.stringify(info),
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
// Act
|
||||
const systemInfo = SystemService.systemInfo();
|
||||
|
||||
// Assert
|
||||
expect(systemInfo).toBeDefined();
|
||||
expect(systemInfo.cpu).toBeDefined();
|
||||
expect(systemInfo.memory).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: getVersion', () => {
|
||||
beforeEach(() => {
|
||||
TipiCache.del('latestVersion');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('It should return version', async () => {
|
||||
// Arrange
|
||||
const spy = jest.spyOn(axios, 'get').mockResolvedValue({
|
||||
data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
|
||||
});
|
||||
|
||||
// Act
|
||||
const version = await SystemService.getVersion();
|
||||
|
||||
// Assert
|
||||
expect(version).toBeDefined();
|
||||
expect(version.current).toBeDefined();
|
||||
expect(semver.valid(version.latest)).toBeTruthy();
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should return undefined for latest if request fails', async () => {
|
||||
jest.spyOn(axios, 'get').mockImplementation(() => {
|
||||
throw new Error('Error');
|
||||
});
|
||||
|
||||
const version = await SystemService.getVersion();
|
||||
|
||||
expect(version).toBeDefined();
|
||||
expect(version.current).toBeDefined();
|
||||
expect(version.latest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should return cached version', async () => {
|
||||
// Arrange
|
||||
const spy = jest.spyOn(axios, 'get').mockResolvedValue({
|
||||
data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
|
||||
});
|
||||
|
||||
// Act
|
||||
const version = await SystemService.getVersion();
|
||||
const version2 = await SystemService.getVersion();
|
||||
|
||||
// Assert
|
||||
expect(version).toBeDefined();
|
||||
expect(version.current).toBeDefined();
|
||||
expect(semver.valid(version.latest)).toBeTruthy();
|
||||
|
||||
expect(version2.latest).toBe(version.latest);
|
||||
expect(version2.current).toBeDefined();
|
||||
expect(semver.valid(version2.latest)).toBeTruthy();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: restart', () => {
|
||||
it('Should return true', async () => {
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
|
||||
|
||||
// Act
|
||||
const restart = await SystemService.restart();
|
||||
|
||||
// Assert
|
||||
expect(restart).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Should log error if fails', async () => {
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'fake' });
|
||||
const log = jest.spyOn(logger, 'error');
|
||||
|
||||
// Act
|
||||
const restart = await SystemService.restart();
|
||||
|
||||
// Assert
|
||||
expect(restart).toBeFalsy();
|
||||
expect(log).toHaveBeenCalledWith('Error restarting system: fake');
|
||||
log.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: update', () => {
|
||||
it('Should return true', async () => {
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
|
||||
setConfig('version', '0.0.1');
|
||||
TipiCache.set('latestVersion', '0.0.2');
|
||||
|
||||
// Act
|
||||
const update = await SystemService.update();
|
||||
|
||||
// Assert
|
||||
expect(update).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Should throw an error if latest version is not set', async () => {
|
||||
// Arrange
|
||||
TipiCache.del('latestVersion');
|
||||
const spy = jest.spyOn(axios, 'get').mockResolvedValue({
|
||||
data: { name: null },
|
||||
});
|
||||
setConfig('version', '0.0.1');
|
||||
|
||||
// Act & Assert
|
||||
await expect(SystemService.update()).rejects.toThrow('Could not get latest version');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should throw if current version is higher than latest', async () => {
|
||||
// Arrange
|
||||
setConfig('version', '0.0.2');
|
||||
TipiCache.set('latestVersion', '0.0.1');
|
||||
|
||||
// Act & Assert
|
||||
await expect(SystemService.update()).rejects.toThrow('Current version is newer than latest version');
|
||||
});
|
||||
|
||||
it('Should throw if current version is equal to latest', async () => {
|
||||
// Arrange
|
||||
setConfig('version', '0.0.1');
|
||||
TipiCache.set('latestVersion', '0.0.1');
|
||||
|
||||
// Act & Assert
|
||||
await expect(SystemService.update()).rejects.toThrow('Current version is already up to date');
|
||||
});
|
||||
|
||||
it('Should throw an error if there is a major version difference', async () => {
|
||||
// Arrange
|
||||
setConfig('version', '0.0.1');
|
||||
TipiCache.set('latestVersion', '1.0.0');
|
||||
|
||||
// Act & Assert
|
||||
await expect(SystemService.update()).rejects.toThrow('The major version has changed. Please update manually');
|
||||
});
|
||||
|
||||
it('Should log error if fails', async () => {
|
||||
// Arrange
|
||||
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'fake2' });
|
||||
const log = jest.spyOn(logger, 'error');
|
||||
|
||||
// Act
|
||||
setConfig('version', '0.0.1');
|
||||
TipiCache.set('latestVersion', '0.0.2');
|
||||
const update = await SystemService.update();
|
||||
|
||||
// Assert
|
||||
expect(update).toBeFalsy();
|
||||
expect(log).toHaveBeenCalledWith('Error updating system: fake2');
|
||||
log.mockRestore();
|
||||
});
|
||||
});
|
12
packages/system-api/src/modules/system/system.controller.ts
Normal file
12
packages/system-api/src/modules/system/system.controller.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { getConfig } from '../../core/config/TipiConfig';
|
||||
|
||||
const status = async (req: Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
status: getConfig().status,
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
status,
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import { Query, Resolver } from 'type-graphql';
|
||||
import { Authorized, Mutation, Query, Resolver } from 'type-graphql';
|
||||
import SystemService from './system.service';
|
||||
import { SystemInfoResponse, VersionResponse } from './system.types';
|
||||
|
||||
|
@ -13,4 +13,16 @@ export default class AuthResolver {
|
|||
async version(): Promise<VersionResponse> {
|
||||
return SystemService.getVersion();
|
||||
}
|
||||
|
||||
@Authorized()
|
||||
@Mutation(() => Boolean)
|
||||
async restart(): Promise<boolean> {
|
||||
return SystemService.restart();
|
||||
}
|
||||
|
||||
@Authorized()
|
||||
@Mutation(() => Boolean)
|
||||
async update(): Promise<boolean> {
|
||||
return SystemService.update();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,28 +1,38 @@
|
|||
import axios from 'axios';
|
||||
import config from '../../config';
|
||||
import z from 'zod';
|
||||
import semver from 'semver';
|
||||
import logger from '../../config/logger/logger';
|
||||
import TipiCache from '../../config/TipiCache';
|
||||
import { getConfig, setConfig } from '../../core/config/TipiConfig';
|
||||
import { readJsonFile } from '../fs/fs.helpers';
|
||||
import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher';
|
||||
|
||||
type SystemInfo = {
|
||||
cpu: {
|
||||
load: number;
|
||||
};
|
||||
disk: {
|
||||
total: number;
|
||||
used: number;
|
||||
available: number;
|
||||
};
|
||||
memory: {
|
||||
total: number;
|
||||
available: number;
|
||||
used: number;
|
||||
};
|
||||
};
|
||||
const systemInfoSchema = z.object({
|
||||
cpu: z.object({
|
||||
load: z.number().default(0),
|
||||
}),
|
||||
disk: z.object({
|
||||
total: z.number().default(0),
|
||||
used: z.number().default(0),
|
||||
available: z.number().default(0),
|
||||
}),
|
||||
memory: z.object({
|
||||
total: z.number().default(0),
|
||||
available: z.number().default(0),
|
||||
used: z.number().default(0),
|
||||
}),
|
||||
});
|
||||
|
||||
const systemInfo = (): SystemInfo => {
|
||||
const info: SystemInfo = readJsonFile('/state/system-info.json');
|
||||
const systemInfo = (): z.infer<typeof systemInfoSchema> => {
|
||||
const info = systemInfoSchema.safeParse(readJsonFile('/runtipi/state/system-info.json'));
|
||||
|
||||
return info;
|
||||
if (!info.success) {
|
||||
logger.error('Error parsing system info');
|
||||
logger.error(info.error);
|
||||
throw new Error('Error parsing system info');
|
||||
} else {
|
||||
return info.data;
|
||||
}
|
||||
};
|
||||
|
||||
const getVersion = async (): Promise<{ current: string; latest?: string }> => {
|
||||
|
@ -38,15 +48,66 @@ const getVersion = async (): Promise<{ current: string; latest?: string }> => {
|
|||
|
||||
TipiCache.set('latestVersion', version?.replace('v', ''));
|
||||
|
||||
return { current: config.VERSION, latest: version?.replace('v', '') };
|
||||
return { current: getConfig().version, latest: version?.replace('v', '') };
|
||||
} catch (e) {
|
||||
return { current: config.VERSION, latest: undefined };
|
||||
logger.error(e);
|
||||
return { current: getConfig().version, latest: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
const restart = async (): Promise<boolean> => {
|
||||
setConfig('status', 'RESTARTING');
|
||||
|
||||
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.RESTART);
|
||||
|
||||
if (!success) {
|
||||
logger.error(`Error restarting system: ${stdout}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
setConfig('status', 'RUNNING');
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const update = async (): Promise<boolean> => {
|
||||
const { current, latest } = await getVersion();
|
||||
|
||||
if (!latest) {
|
||||
throw new Error('Could not get latest version');
|
||||
}
|
||||
|
||||
if (semver.gt(current, latest)) {
|
||||
throw new Error('Current version is newer than latest version');
|
||||
}
|
||||
|
||||
if (semver.eq(current, latest)) {
|
||||
throw new Error('Current version is already up to date');
|
||||
}
|
||||
|
||||
if (semver.major(current) !== semver.major(latest)) {
|
||||
throw new Error('The major version has changed. Please update manually');
|
||||
}
|
||||
|
||||
setConfig('status', 'UPDATING');
|
||||
|
||||
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.UPDATE);
|
||||
|
||||
if (!success) {
|
||||
logger.error(`Error updating system: ${stdout}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
setConfig('status', 'RUNNING');
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const SystemService = {
|
||||
systemInfo,
|
||||
getVersion,
|
||||
restart,
|
||||
update,
|
||||
};
|
||||
|
||||
export default SystemService;
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import 'reflect-metadata';
|
||||
import express from 'express';
|
||||
import { ApolloServerPluginLandingPageGraphQLPlayground as Playground } from 'apollo-server-core';
|
||||
import config from './config';
|
||||
import { ApolloServer } from 'apollo-server-express';
|
||||
import { createSchema } from './schema';
|
||||
import { ApolloLogs } from './config/logger/apollo.logger';
|
||||
|
@ -15,8 +14,11 @@ import datasource from './config/datasource';
|
|||
import appsService from './modules/apps/apps.service';
|
||||
import { runUpdates } from './core/updates/run';
|
||||
import recover from './core/updates/recover-migrations';
|
||||
import { cloneRepo, updateRepo } from './helpers/repo-helpers';
|
||||
import startJobs from './core/jobs/jobs';
|
||||
import { applyJsonConfig, getConfig, setConfig } from './core/config/TipiConfig';
|
||||
import { ZodError } from 'zod';
|
||||
import systemController from './modules/system/system.controller';
|
||||
import { eventDispatcher, EventTypes } from './core/config/EventDispatcher';
|
||||
|
||||
let corsOptions = {
|
||||
credentials: true,
|
||||
|
@ -27,7 +29,7 @@ let corsOptions = {
|
|||
// disallow requests with no origin
|
||||
if (!origin) return callback(new Error('Not allowed by CORS'), false);
|
||||
|
||||
if (config.CLIENT_URLS.includes(origin)) {
|
||||
if (getConfig().clientUrls.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
|
@ -36,12 +38,29 @@ let corsOptions = {
|
|||
},
|
||||
};
|
||||
|
||||
const applyCustomConfig = () => {
|
||||
try {
|
||||
applyJsonConfig();
|
||||
} catch (e) {
|
||||
logger.error('Error applying settings.json config');
|
||||
if (e instanceof ZodError) {
|
||||
Object.keys(e.flatten().fieldErrors).forEach((key) => {
|
||||
logger.error(`Error in field ${key}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
try {
|
||||
eventDispatcher.clear();
|
||||
applyCustomConfig();
|
||||
|
||||
const app = express();
|
||||
const port = 3001;
|
||||
|
||||
app.use(express.static(`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}`));
|
||||
app.use(express.static(`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}`));
|
||||
app.use('/status', systemController.status);
|
||||
app.use(cors(corsOptions));
|
||||
app.use(getSessionMiddleware());
|
||||
|
||||
|
@ -68,22 +87,24 @@ const main = async () => {
|
|||
await datasource.runMigrations();
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
await recover();
|
||||
await recover(datasource);
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
await runUpdates();
|
||||
|
||||
httpServer.listen(port, async () => {
|
||||
await cloneRepo(config.APPS_REPO_URL);
|
||||
await updateRepo(config.APPS_REPO_URL);
|
||||
await eventDispatcher.dispatchEventAsync(EventTypes.CLONE_REPO, [getConfig().appsRepoUrl]);
|
||||
await eventDispatcher.dispatchEventAsync(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]);
|
||||
|
||||
startJobs();
|
||||
setConfig('status', 'RUNNING');
|
||||
|
||||
// Start apps
|
||||
appsService.startAllApps();
|
||||
console.info(`Server running on port ${port} 🚀 Production => ${__prod__}`);
|
||||
logger.info(`Server running on port ${port} 🚀 Production => ${__prod__}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -38,7 +38,8 @@ export const setupConnection = async (testsuite: string): Promise<DataSource> =>
|
|||
entities: [App, User, Update],
|
||||
});
|
||||
|
||||
return AppDataSource.initialize();
|
||||
await AppDataSource.initialize();
|
||||
return AppDataSource;
|
||||
};
|
||||
|
||||
export const teardownConnection = async (testsuite: string): Promise<void> => {
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
import { eventDispatcher } from '../core/config/EventDispatcher';
|
||||
|
||||
jest.mock('../config/logger/logger', () => ({
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
}));
|
||||
|
||||
afterAll(() => {
|
||||
eventDispatcher.clearInterval();
|
||||
});
|
||||
|
|
|
@ -10,6 +10,8 @@ import * as updateAppConfig from './updateAppConfig.graphql';
|
|||
import * as updateApp from './updateApp.graphql';
|
||||
import * as register from './register.graphql';
|
||||
import * as login from './login.graphql';
|
||||
import * as restart from './restart.graphql';
|
||||
import * as update from './update.graphql';
|
||||
|
||||
export const installAppMutation = print(installApp);
|
||||
export const startAppMutation = print(startApp);
|
||||
|
@ -19,3 +21,5 @@ export const updateAppConfigMutation = print(updateAppConfig);
|
|||
export const updateAppMutation = print(updateApp);
|
||||
export const registerMutation = print(register);
|
||||
export const loginMutation = print(login);
|
||||
export const restartMutation = print(restart);
|
||||
export const updateMutation = print(update);
|
||||
|
|
3
packages/system-api/src/test/mutations/restart.graphql
Normal file
3
packages/system-api/src/test/mutations/restart.graphql
Normal file
|
@ -0,0 +1,3 @@
|
|||
mutation {
|
||||
restart
|
||||
}
|
3
packages/system-api/src/test/mutations/update.graphql
Normal file
3
packages/system-api/src/test/mutations/update.graphql
Normal file
|
@ -0,0 +1,3 @@
|
|||
mutation {
|
||||
update
|
||||
}
|
|
@ -7,9 +7,13 @@ import * as getApp from './getApp.graphql';
|
|||
import * as InstalledApps from './installedApps.graphql';
|
||||
import * as Me from './me.graphql';
|
||||
import * as isConfigured from './isConfigured.graphql';
|
||||
import * as systemInfo from './systemInfo.graphql';
|
||||
import * as version from './version.graphql';
|
||||
|
||||
export const listAppInfosQuery = print(listAppInfos);
|
||||
export const getAppQuery = print(getApp);
|
||||
export const InstalledAppsQuery = print(InstalledApps);
|
||||
export const MeQuery = print(Me);
|
||||
export const isConfiguredQuery = print(isConfigured);
|
||||
export const systemInfoQuery = print(systemInfo);
|
||||
export const versionQuery = print(version);
|
||||
|
|
17
packages/system-api/src/test/queries/systemInfo.graphql
Normal file
17
packages/system-api/src/test/queries/systemInfo.graphql
Normal file
|
@ -0,0 +1,17 @@
|
|||
query {
|
||||
systemInfo {
|
||||
cpu {
|
||||
load
|
||||
}
|
||||
memory {
|
||||
total
|
||||
available
|
||||
used
|
||||
}
|
||||
disk {
|
||||
total
|
||||
available
|
||||
used
|
||||
}
|
||||
}
|
||||
}
|
6
packages/system-api/src/test/queries/version.graphql
Normal file
6
packages/system-api/src/test/queries/version.graphql
Normal file
|
@ -0,0 +1,6 @@
|
|||
query {
|
||||
version {
|
||||
current
|
||||
latest
|
||||
}
|
||||
}
|
321
pnpm-lock.yaml
generated
321
pnpm-lock.yaml
generated
|
@ -39,7 +39,6 @@ importers:
|
|||
'@typescript-eslint/eslint-plugin': ^5.18.0
|
||||
'@typescript-eslint/parser': ^5.0.0
|
||||
autoprefixer: ^10.4.4
|
||||
axios: ^0.26.1
|
||||
clsx: ^1.1.1
|
||||
eslint: 8.12.0
|
||||
eslint-config-airbnb-typescript: ^17.0.0
|
||||
|
@ -49,9 +48,7 @@ importers:
|
|||
framer-motion: ^6
|
||||
graphql: ^15.8.0
|
||||
graphql-tag: ^2.12.6
|
||||
immer: ^9.0.12
|
||||
jest: ^28.1.0
|
||||
js-cookie: ^3.0.1
|
||||
next: 12.1.6
|
||||
postcss: ^8.4.12
|
||||
react: 18.1.0
|
||||
|
@ -64,7 +61,6 @@ importers:
|
|||
remark-gfm: ^3.0.1
|
||||
remark-mdx: ^2.1.1
|
||||
swr: ^1.3.0
|
||||
systeminformation: ^5.11.9
|
||||
tailwindcss: ^3.0.23
|
||||
ts-jest: ^28.0.2
|
||||
tslib: ^2.4.0
|
||||
|
@ -77,14 +73,11 @@ importers:
|
|||
'@emotion/react': 11.9.0_4mdsreeeydipjms3kbrjyybtve
|
||||
'@emotion/styled': 11.8.1_tnefweo2a67ybg6wfzi6ieqilm
|
||||
'@fontsource/open-sans': 4.5.8
|
||||
axios: 0.26.1
|
||||
clsx: 1.1.1
|
||||
final-form: 4.20.7
|
||||
framer-motion: 6.3.3_ef5jwxihqo6n7gxfmzogljlgcm
|
||||
graphql: 15.8.0
|
||||
graphql-tag: 2.12.6_graphql@15.8.0
|
||||
immer: 9.0.12
|
||||
js-cookie: 3.0.1
|
||||
next: 12.1.6_talmm3uuvp6ssixt2qevhfgvue
|
||||
react: 18.1.0
|
||||
react-dom: 18.1.0_react@18.1.0
|
||||
|
@ -96,7 +89,6 @@ importers:
|
|||
remark-gfm: 3.0.1
|
||||
remark-mdx: 2.1.1
|
||||
swr: 1.3.0_react@18.1.0
|
||||
systeminformation: 5.11.14
|
||||
tslib: 2.4.0
|
||||
validator: 13.7.0
|
||||
zustand: 3.7.2_react@18.1.0
|
||||
|
@ -130,18 +122,15 @@ importers:
|
|||
'@faker-js/faker': ^7.3.0
|
||||
'@swc/cli': ^0.1.57
|
||||
'@swc/core': ^1.2.210
|
||||
'@types/compression': ^1.7.2
|
||||
'@types/cookie-parser': ^1.4.3
|
||||
'@types/cors': ^2.8.12
|
||||
'@types/express': ^4.17.13
|
||||
'@types/express-session': ^1.17.4
|
||||
'@types/fs-extra': ^9.0.13
|
||||
'@types/jest': ^27.5.0
|
||||
'@types/jsonwebtoken': ^8.5.8
|
||||
'@types/mock-fs': ^4.13.1
|
||||
'@types/node': 17.0.31
|
||||
'@types/node-cron': ^3.0.2
|
||||
'@types/pg': ^8.6.5
|
||||
'@types/semver': ^7.3.12
|
||||
'@types/session-file-store': ^1.2.2
|
||||
'@types/tcp-port-used': ^1.0.1
|
||||
'@types/validator': ^13.7.2
|
||||
|
@ -152,9 +141,7 @@ importers:
|
|||
argon2: ^0.29.1
|
||||
axios: ^0.26.1
|
||||
class-validator: ^0.13.2
|
||||
compression: ^1.7.4
|
||||
concurrently: ^7.1.0
|
||||
cookie-parser: ^1.4.6
|
||||
cors: ^2.8.5
|
||||
dotenv: ^16.0.0
|
||||
eslint: ^8.13.0
|
||||
|
@ -171,20 +158,16 @@ importers:
|
|||
http: 0.0.1-security
|
||||
internal-ip: ^6.0.0
|
||||
jest: ^28.1.0
|
||||
jsonwebtoken: ^8.5.1
|
||||
mock-fs: ^5.1.2
|
||||
node-cache: ^5.1.2
|
||||
node-cron: ^3.0.1
|
||||
node-port-scanner: ^3.0.1
|
||||
nodemon: ^2.0.15
|
||||
p-iteration: ^1.1.8
|
||||
pg: ^8.7.3
|
||||
prettier: 2.6.2
|
||||
public-ip: ^5.0.0
|
||||
reflect-metadata: ^0.1.13
|
||||
rimraf: ^3.0.2
|
||||
semver: ^7.3.7
|
||||
session-file-store: ^1.5.0
|
||||
systeminformation: ^5.11.9
|
||||
tcp-port-used: ^1.0.2
|
||||
ts-jest: ^28.0.2
|
||||
ts-node: ^10.8.2
|
||||
|
@ -193,14 +176,13 @@ importers:
|
|||
typescript: 4.6.4
|
||||
validator: ^13.7.0
|
||||
winston: ^3.7.2
|
||||
zod: ^3.19.1
|
||||
dependencies:
|
||||
apollo-server-core: 3.10.0_graphql@15.8.0
|
||||
apollo-server-express: 3.9.0_jfj6k5cqxqbusbdzwqjdzioxzm
|
||||
argon2: 0.29.1
|
||||
axios: 0.26.1
|
||||
class-validator: 0.13.2
|
||||
compression: 1.7.4
|
||||
cookie-parser: 1.4.6
|
||||
cors: 2.8.5
|
||||
dotenv: 16.0.0
|
||||
express: 4.18.1
|
||||
|
@ -210,38 +192,32 @@ importers:
|
|||
graphql-type-json: 0.3.2_graphql@15.8.0
|
||||
http: 0.0.1-security
|
||||
internal-ip: 6.2.0
|
||||
jsonwebtoken: 8.5.1
|
||||
mock-fs: 5.1.2
|
||||
node-cache: 5.1.2
|
||||
node-cron: 3.0.1
|
||||
node-port-scanner: 3.0.1
|
||||
p-iteration: 1.1.8
|
||||
pg: 8.7.3
|
||||
public-ip: 5.0.0
|
||||
reflect-metadata: 0.1.13
|
||||
semver: 7.3.7
|
||||
session-file-store: 1.5.0
|
||||
systeminformation: 5.11.14
|
||||
tcp-port-used: 1.0.2
|
||||
type-graphql: 1.1.1_v2revtygxcm7xrdg2oz3ssohfu
|
||||
typeorm: 0.3.6_pg@8.7.3+ts-node@10.8.2
|
||||
validator: 13.7.0
|
||||
winston: 3.7.2
|
||||
zod: 3.19.1
|
||||
devDependencies:
|
||||
'@faker-js/faker': 7.3.0
|
||||
'@swc/cli': 0.1.57_@swc+core@1.2.210
|
||||
'@swc/core': 1.2.210
|
||||
'@types/compression': 1.7.2
|
||||
'@types/cookie-parser': 1.4.3
|
||||
'@types/cors': 2.8.12
|
||||
'@types/express': 4.17.13
|
||||
'@types/express-session': 1.17.4
|
||||
'@types/fs-extra': 9.0.13
|
||||
'@types/jest': 27.5.0
|
||||
'@types/jsonwebtoken': 8.5.8
|
||||
'@types/mock-fs': 4.13.1
|
||||
'@types/node': 17.0.31
|
||||
'@types/node-cron': 3.0.2
|
||||
'@types/pg': 8.6.5
|
||||
'@types/semver': 7.3.12
|
||||
'@types/session-file-store': 1.2.2
|
||||
'@types/tcp-port-used': 1.0.1
|
||||
'@types/validator': 13.7.2
|
||||
|
@ -3167,10 +3143,6 @@ packages:
|
|||
'@jridgewell/sourcemap-codec': 1.4.13
|
||||
dev: true
|
||||
|
||||
/@leichtgewicht/ip-codec/2.0.3:
|
||||
resolution: {integrity: sha512-nkalE/f1RvRGChwBnEIoBfSEYOXnCRdleKuv6+lePbMDrMZXeDQnqak5XDOeBgrPPyPfAdcCu/B5z+v3VhplGg==}
|
||||
dev: false
|
||||
|
||||
/@mapbox/node-pre-gyp/1.0.9:
|
||||
resolution: {integrity: sha512-aDF3S3rK9Q2gey/WAttUlISduDItz5BU3306M9Eyv6/oS40aMprnopshtlKTykxRNIBEZuRMaZAnbrQ4QtKGyw==}
|
||||
hasBin: true
|
||||
|
@ -3419,11 +3391,6 @@ packages:
|
|||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/@sindresorhus/is/4.6.0:
|
||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||
engines: {node: '>=10'}
|
||||
dev: false
|
||||
|
||||
/@sinonjs/commons/1.8.3:
|
||||
resolution: {integrity: sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==}
|
||||
dependencies:
|
||||
|
@ -3602,13 +3569,6 @@ packages:
|
|||
defer-to-connect: 1.1.3
|
||||
dev: true
|
||||
|
||||
/@szmarczak/http-timer/5.0.1:
|
||||
resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==}
|
||||
engines: {node: '>=14.16'}
|
||||
dependencies:
|
||||
defer-to-connect: 2.0.1
|
||||
dev: false
|
||||
|
||||
/@tootallnate/once/2.0.0:
|
||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||
engines: {node: '>= 10'}
|
||||
|
@ -3677,32 +3637,11 @@ packages:
|
|||
'@types/connect': 3.4.35
|
||||
'@types/node': 17.0.31
|
||||
|
||||
/@types/cacheable-request/6.0.2:
|
||||
resolution: {integrity: sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==}
|
||||
dependencies:
|
||||
'@types/http-cache-semantics': 4.0.1
|
||||
'@types/keyv': 3.1.4
|
||||
'@types/node': 17.0.31
|
||||
'@types/responselike': 1.0.0
|
||||
dev: false
|
||||
|
||||
/@types/compression/1.7.2:
|
||||
resolution: {integrity: sha512-lwEL4M/uAGWngWFLSG87ZDr2kLrbuR8p7X+QZB1OQlT+qkHsCPDVFnHPyXf4Vyl4yDDorNY+mAhosxkCvppatg==}
|
||||
dependencies:
|
||||
'@types/express': 4.17.13
|
||||
dev: true
|
||||
|
||||
/@types/connect/3.4.35:
|
||||
resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
|
||||
dependencies:
|
||||
'@types/node': 17.0.31
|
||||
|
||||
/@types/cookie-parser/1.4.3:
|
||||
resolution: {integrity: sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==}
|
||||
dependencies:
|
||||
'@types/express': 4.17.13
|
||||
dev: true
|
||||
|
||||
/@types/cors/2.8.12:
|
||||
resolution: {integrity: sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==}
|
||||
|
||||
|
@ -3776,10 +3715,6 @@ packages:
|
|||
'@types/unist': 2.0.6
|
||||
dev: false
|
||||
|
||||
/@types/http-cache-semantics/4.0.1:
|
||||
resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==}
|
||||
dev: false
|
||||
|
||||
/@types/istanbul-lib-coverage/2.0.4:
|
||||
resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==}
|
||||
dev: true
|
||||
|
@ -3811,10 +3746,6 @@ packages:
|
|||
resolution: {integrity: sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==}
|
||||
dev: true
|
||||
|
||||
/@types/json-buffer/3.0.0:
|
||||
resolution: {integrity: sha512-3YP80IxxFJB4b5tYC2SUPwkg0XQLiu0nWvhRgEatgjf+29IcWO9X1k8xRv5DGssJ/lCrjYTjQPcobJr2yWIVuQ==}
|
||||
dev: false
|
||||
|
||||
/@types/json-schema/7.0.11:
|
||||
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
|
||||
dev: true
|
||||
|
@ -3837,6 +3768,7 @@ packages:
|
|||
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
|
||||
dependencies:
|
||||
'@types/node': 17.0.31
|
||||
dev: true
|
||||
|
||||
/@types/lodash.mergewith/4.6.6:
|
||||
resolution: {integrity: sha512-RY/8IaVENjG19rxTZu9Nukqh0W2UrYgmBj5sdns4hWRZaV8PqR7wIKHFKzvOTjo4zVRV7sVI+yFhAJql12Kfqg==}
|
||||
|
@ -3873,12 +3805,6 @@ packages:
|
|||
resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==}
|
||||
dev: true
|
||||
|
||||
/@types/mock-fs/4.13.1:
|
||||
resolution: {integrity: sha512-m6nFAJ3lBSnqbvDZioawRvpLXSaPyn52Srf7OfzjubYbYX8MTUdIgDxQl0wEapm4m/pNYSd9TXocpQ0TvZFlYA==}
|
||||
dependencies:
|
||||
'@types/node': 17.0.31
|
||||
dev: true
|
||||
|
||||
/@types/ms/0.7.31:
|
||||
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
|
||||
dev: false
|
||||
|
@ -3951,13 +3877,13 @@ packages:
|
|||
resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==}
|
||||
dependencies:
|
||||
'@types/node': 17.0.31
|
||||
dev: true
|
||||
|
||||
/@types/scheduler/0.16.2:
|
||||
resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
|
||||
|
||||
/@types/semver/7.3.10:
|
||||
resolution: {integrity: sha512-zsv3fsC7S84NN6nPK06u79oWgrPVd0NvOyqgghV1haPaFcVxIrP4DLomRwGAXk0ui4HZA7mOcSFL98sMVW9viw==}
|
||||
dev: false
|
||||
/@types/semver/7.3.12:
|
||||
resolution: {integrity: sha512-WwA1MW0++RfXmCr12xeYOOC5baSC9mSb0ZqCquFzKhcoF4TvHu5MKOuXsncgZcpVFhB1pXd5hZmM0ryAoCp12A==}
|
||||
|
||||
/@types/serve-static/1.13.10:
|
||||
resolution: {integrity: sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==}
|
||||
|
@ -5023,6 +4949,7 @@ packages:
|
|||
|
||||
/buffer-equal-constant-time/1.0.1:
|
||||
resolution: {integrity: sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=}
|
||||
dev: true
|
||||
|
||||
/buffer-from/1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
@ -5054,21 +4981,11 @@ packages:
|
|||
streamsearch: 1.1.0
|
||||
dev: true
|
||||
|
||||
/bytes/3.0.0:
|
||||
resolution: {integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/bytes/3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/cacheable-lookup/6.0.4:
|
||||
resolution: {integrity: sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A==}
|
||||
engines: {node: '>=10.6.0'}
|
||||
dev: false
|
||||
|
||||
/cacheable-request/6.1.0:
|
||||
resolution: {integrity: sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -5082,19 +4999,6 @@ packages:
|
|||
responselike: 1.0.2
|
||||
dev: true
|
||||
|
||||
/cacheable-request/7.0.2:
|
||||
resolution: {integrity: sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
clone-response: 1.0.2
|
||||
get-stream: 5.2.0
|
||||
http-cache-semantics: 4.1.0
|
||||
keyv: 4.2.2
|
||||
lowercase-keys: 2.0.0
|
||||
normalize-url: 6.1.0
|
||||
responselike: 2.0.0
|
||||
dev: false
|
||||
|
||||
/cachedir/2.3.0:
|
||||
resolution: {integrity: sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==}
|
||||
engines: {node: '>=6'}
|
||||
|
@ -5347,6 +5251,7 @@ packages:
|
|||
resolution: {integrity: sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q==}
|
||||
dependencies:
|
||||
mimic-response: 1.0.1
|
||||
dev: true
|
||||
|
||||
/clone/1.0.4:
|
||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||
|
@ -5476,36 +5381,6 @@ packages:
|
|||
dot-prop: 5.3.0
|
||||
dev: true
|
||||
|
||||
/compress-brotli/1.3.8:
|
||||
resolution: {integrity: sha512-lVcQsjhxhIXsuupfy9fmZUFtAIdBmXA7EGY6GBdgZ++qkM9zG4YFT8iU7FoBxzryNDMOpD1HIFHUSX4D87oqhQ==}
|
||||
engines: {node: '>= 12'}
|
||||
dependencies:
|
||||
'@types/json-buffer': 3.0.0
|
||||
json-buffer: 3.0.1
|
||||
dev: false
|
||||
|
||||
/compressible/2.0.18:
|
||||
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
dev: false
|
||||
|
||||
/compression/1.7.4:
|
||||
resolution: {integrity: sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
accepts: 1.3.8
|
||||
bytes: 3.0.0
|
||||
compressible: 2.0.18
|
||||
debug: 2.6.9
|
||||
on-headers: 1.0.2
|
||||
safe-buffer: 5.1.2
|
||||
vary: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/compute-scroll-into-view/1.0.14:
|
||||
resolution: {integrity: sha512-mKDjINe3tc6hGelUMNDzuhorIUZ7kS7BwyY0r2wQd2HOH2tRuJykiC06iSEX8y1TuhNzvz4GcJnK16mM2J1NMQ==}
|
||||
dev: false
|
||||
|
@ -5607,23 +5482,10 @@ packages:
|
|||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
/cookie-parser/1.4.6:
|
||||
resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
cookie: 0.4.1
|
||||
cookie-signature: 1.0.6
|
||||
dev: false
|
||||
|
||||
/cookie-signature/1.0.6:
|
||||
resolution: {integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw=}
|
||||
dev: false
|
||||
|
||||
/cookie/0.4.1:
|
||||
resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/cookie/0.4.2:
|
||||
resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
@ -5879,13 +5741,6 @@ packages:
|
|||
mimic-response: 1.0.1
|
||||
dev: true
|
||||
|
||||
/decompress-response/6.0.0:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
dev: false
|
||||
|
||||
/dedent/0.7.0:
|
||||
resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
|
||||
dev: true
|
||||
|
@ -5920,11 +5775,6 @@ packages:
|
|||
resolution: {integrity: sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==}
|
||||
dev: true
|
||||
|
||||
/defer-to-connect/2.0.1:
|
||||
resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==}
|
||||
engines: {node: '>=10'}
|
||||
dev: false
|
||||
|
||||
/define-properties/1.1.4:
|
||||
resolution: {integrity: sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
@ -6035,20 +5885,6 @@ packages:
|
|||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
dev: true
|
||||
|
||||
/dns-packet/5.3.1:
|
||||
resolution: {integrity: sha512-spBwIj0TK0Ey3666GwIdWVfUpLyubpU53BTCu8iPn4r4oXd9O14Hjg3EHw3ts2oed77/SeckunUYCyRlSngqHw==}
|
||||
engines: {node: '>=6'}
|
||||
dependencies:
|
||||
'@leichtgewicht/ip-codec': 2.0.3
|
||||
dev: false
|
||||
|
||||
/dns-socket/4.2.2:
|
||||
resolution: {integrity: sha512-BDeBd8najI4/lS00HSKpdFia+OvUMytaVjfzR9n5Lq8MlZRSvtbI+uLtx1+XmQFls5wFU9dssccTmQQ6nfpjdg==}
|
||||
engines: {node: '>=6'}
|
||||
dependencies:
|
||||
dns-packet: 5.3.1
|
||||
dev: false
|
||||
|
||||
/doctrine/2.1.0:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
@ -6101,6 +5937,7 @@ packages:
|
|||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
dev: true
|
||||
|
||||
/ee-first/1.1.1:
|
||||
resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=}
|
||||
|
@ -6140,6 +5977,7 @@ packages:
|
|||
resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
dev: true
|
||||
|
||||
/error-ex/1.3.2:
|
||||
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
|
||||
|
@ -6398,7 +6236,7 @@ packages:
|
|||
eslint-import-resolver-webpack:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@typescript-eslint/parser': 5.22.0_hcfsmds2fshutdssjqluwm76uu
|
||||
'@typescript-eslint/parser': 5.22.0_uhoeudlwl7kc47h4kncsfowede
|
||||
debug: 3.2.7
|
||||
eslint-import-resolver-node: 0.3.6
|
||||
find-up: 2.1.0
|
||||
|
@ -7078,6 +6916,7 @@ packages:
|
|||
|
||||
/form-data-encoder/1.7.1:
|
||||
resolution: {integrity: sha512-EFRDrsMm/kyqbTQocNvRXMLjc7Es2Vk+IQFx/YW7hkUH1eBl4J1fqiP34l74Yt0pFLCNpc06fkbVk00008mzjg==}
|
||||
dev: true
|
||||
|
||||
/form-data/3.0.1:
|
||||
resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==}
|
||||
|
@ -7258,6 +7097,7 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
pump: 3.0.0
|
||||
dev: true
|
||||
|
||||
/get-stream/6.0.1:
|
||||
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
|
||||
|
@ -7386,25 +7226,6 @@ packages:
|
|||
slash: 3.0.0
|
||||
dev: true
|
||||
|
||||
/got/12.0.4:
|
||||
resolution: {integrity: sha512-2Eyz4iU/ktq7wtMFXxzK7g5p35uNYLLdiZarZ5/Yn3IJlNEpBd5+dCgcAyxN8/8guZLszffwe3wVyw+DEVrpBg==}
|
||||
engines: {node: '>=14.16'}
|
||||
dependencies:
|
||||
'@sindresorhus/is': 4.6.0
|
||||
'@szmarczak/http-timer': 5.0.1
|
||||
'@types/cacheable-request': 6.0.2
|
||||
'@types/responselike': 1.0.0
|
||||
cacheable-lookup: 6.0.4
|
||||
cacheable-request: 7.0.2
|
||||
decompress-response: 6.0.0
|
||||
form-data-encoder: 1.7.1
|
||||
get-stream: 6.0.1
|
||||
http2-wrapper: 2.1.11
|
||||
lowercase-keys: 3.0.0
|
||||
p-cancelable: 3.0.0
|
||||
responselike: 2.0.0
|
||||
dev: false
|
||||
|
||||
/got/9.6.0:
|
||||
resolution: {integrity: sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
@ -7637,6 +7458,7 @@ packages:
|
|||
|
||||
/http-cache-semantics/4.1.0:
|
||||
resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==}
|
||||
dev: true
|
||||
|
||||
/http-errors/2.0.0:
|
||||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||
|
@ -7664,14 +7486,6 @@ packages:
|
|||
resolution: {integrity: sha512-RnDvP10Ty9FxqOtPZuxtebw1j4L/WiqNMDtuc1YMH1XQm5TgDRaR1G9u8upL6KD1bXHSp9eSXo/ED+8Q7FAr+g==}
|
||||
dev: false
|
||||
|
||||
/http2-wrapper/2.1.11:
|
||||
resolution: {integrity: sha512-aNAk5JzLturWEUiuhAN73Jcbq96R7rTitAoXV54FYMatvihnpD2+6PUgU4ce3D/m5VDbw+F5CsyKSF176ptitQ==}
|
||||
engines: {node: '>=10.19.0'}
|
||||
dependencies:
|
||||
quick-lru: 5.1.1
|
||||
resolve-alpn: 1.2.1
|
||||
dev: false
|
||||
|
||||
/https-proxy-agent/5.0.1:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
@ -7709,10 +7523,6 @@ packages:
|
|||
engines: {node: '>= 4'}
|
||||
dev: true
|
||||
|
||||
/immer/9.0.12:
|
||||
resolution: {integrity: sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==}
|
||||
dev: false
|
||||
|
||||
/immutable/3.7.6:
|
||||
resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
|
@ -8316,7 +8126,7 @@ packages:
|
|||
chalk: 4.1.2
|
||||
ci-info: 3.3.0
|
||||
deepmerge: 4.2.2
|
||||
glob: 7.2.3
|
||||
glob: 7.2.0
|
||||
graceful-fs: 4.2.10
|
||||
jest-circus: 28.1.0
|
||||
jest-environment-node: 28.1.0
|
||||
|
@ -8722,11 +8532,6 @@ packages:
|
|||
- ts-node
|
||||
dev: true
|
||||
|
||||
/js-cookie/3.0.1:
|
||||
resolution: {integrity: sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/js-tokens/4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
|
@ -8754,10 +8559,6 @@ packages:
|
|||
resolution: {integrity: sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==}
|
||||
dev: true
|
||||
|
||||
/json-buffer/3.0.1:
|
||||
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
||||
dev: false
|
||||
|
||||
/json-parse-even-better-errors/2.3.1:
|
||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||
|
||||
|
@ -8836,6 +8637,7 @@ packages:
|
|||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 5.7.1
|
||||
dev: true
|
||||
|
||||
/jsx-ast-utils/3.3.0:
|
||||
resolution: {integrity: sha512-XzO9luP6L0xkxwhIJMTJQpZo/eeN60K08jHdexfD569AGxeNug6UketeHXEhROoM8aR7EcUoOQmIhcJQjcuq8Q==}
|
||||
|
@ -8851,12 +8653,14 @@ packages:
|
|||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
dev: true
|
||||
|
||||
/jws/3.2.2:
|
||||
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
|
||||
dependencies:
|
||||
jwa: 1.4.1
|
||||
safe-buffer: 5.2.1
|
||||
dev: true
|
||||
|
||||
/keyv/3.1.0:
|
||||
resolution: {integrity: sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==}
|
||||
|
@ -8864,13 +8668,6 @@ packages:
|
|||
json-buffer: 3.0.0
|
||||
dev: true
|
||||
|
||||
/keyv/4.2.2:
|
||||
resolution: {integrity: sha512-uYS0vKTlBIjNCAUqrjlxmruxOEiZxZIHXyp32sdcGmP+ukFrmWUnE//RcPXJH3Vxrni1H2gsQbjHE0bH7MtMQQ==}
|
||||
dependencies:
|
||||
compress-brotli: 1.3.8
|
||||
json-buffer: 3.0.1
|
||||
dev: false
|
||||
|
||||
/kind-of/6.0.3:
|
||||
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
@ -9016,21 +8813,27 @@ packages:
|
|||
|
||||
/lodash.includes/4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
dev: true
|
||||
|
||||
/lodash.isboolean/3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
dev: true
|
||||
|
||||
/lodash.isinteger/4.0.4:
|
||||
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||
dev: true
|
||||
|
||||
/lodash.isnumber/3.0.3:
|
||||
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||
dev: true
|
||||
|
||||
/lodash.isplainobject/4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
dev: true
|
||||
|
||||
/lodash.isstring/4.0.1:
|
||||
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||
dev: true
|
||||
|
||||
/lodash.map/4.6.0:
|
||||
resolution: {integrity: sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==}
|
||||
|
@ -9050,6 +8853,7 @@ packages:
|
|||
|
||||
/lodash.once/4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
dev: true
|
||||
|
||||
/lodash.sortby/4.7.0:
|
||||
resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
|
||||
|
@ -9137,11 +8941,7 @@ packages:
|
|||
/lowercase-keys/2.0.0:
|
||||
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
/lowercase-keys/3.0.0:
|
||||
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
dev: false
|
||||
dev: true
|
||||
|
||||
/lru-cache/6.0.0:
|
||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||
|
@ -9790,11 +9590,7 @@ packages:
|
|||
/mimic-response/1.0.1:
|
||||
resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
/mimic-response/3.1.0:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
dev: false
|
||||
dev: true
|
||||
|
||||
/min-indent/1.0.1:
|
||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||
|
@ -9850,11 +9646,6 @@ packages:
|
|||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
/mock-fs/5.1.2:
|
||||
resolution: {integrity: sha512-YkjQkdLulFrz0vD4BfNQdQRVmgycXTV7ykuHMlyv+C8WCHazpkiQRDthwa02kSyo8wKnY9wRptHfQLgmf0eR+A==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
dev: false
|
||||
|
||||
/mri/1.2.0:
|
||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||
engines: {node: '>=4'}
|
||||
|
@ -10062,11 +9853,6 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/normalize-url/6.1.0:
|
||||
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
|
||||
engines: {node: '>=10'}
|
||||
dev: false
|
||||
|
||||
/npm-run-path/4.0.1:
|
||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -10232,11 +10018,6 @@ packages:
|
|||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/p-cancelable/3.0.0:
|
||||
resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==}
|
||||
engines: {node: '>=12.20'}
|
||||
dev: false
|
||||
|
||||
/p-event/4.2.0:
|
||||
resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -10249,11 +10030,6 @@ packages:
|
|||
engines: {node: '>=4'}
|
||||
dev: false
|
||||
|
||||
/p-iteration/1.1.8:
|
||||
resolution: {integrity: sha512-IMFBSDIYcPNnW7uWYGrBqmvTiq7W0uB0fJn6shQZs7dlF3OvrHOre+JT9ikSZ7gZS3vWqclVgoQSvToJrns7uQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
dev: false
|
||||
|
||||
/p-limit/1.3.0:
|
||||
resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==}
|
||||
engines: {node: '>=4'}
|
||||
|
@ -10701,20 +10477,12 @@ packages:
|
|||
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
|
||||
dev: true
|
||||
|
||||
/public-ip/5.0.0:
|
||||
resolution: {integrity: sha512-xaH3pZMni/R2BG7ZXXaWS9Wc9wFlhyDVJF47IJ+3ali0TGv+2PsckKxbmo+rnx3ZxiV2wblVhtdS3bohAP6GGw==}
|
||||
engines: {node: ^14.13.1 || >=16.0.0}
|
||||
dependencies:
|
||||
dns-socket: 4.2.2
|
||||
got: 12.0.4
|
||||
is-ip: 3.1.0
|
||||
dev: false
|
||||
|
||||
/pump/3.0.0:
|
||||
resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==}
|
||||
dependencies:
|
||||
end-of-stream: 1.4.4
|
||||
once: 1.4.0
|
||||
dev: true
|
||||
|
||||
/punycode/2.1.1:
|
||||
resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==}
|
||||
|
@ -10752,6 +10520,7 @@ packages:
|
|||
/quick-lru/5.1.1:
|
||||
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/random-bytes/1.0.0:
|
||||
resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==}
|
||||
|
@ -11138,10 +10907,6 @@ packages:
|
|||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
dev: true
|
||||
|
||||
/resolve-alpn/1.2.1:
|
||||
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
||||
dev: false
|
||||
|
||||
/resolve-cwd/3.0.0:
|
||||
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -11199,12 +10964,6 @@ packages:
|
|||
lowercase-keys: 1.0.1
|
||||
dev: true
|
||||
|
||||
/responselike/2.0.0:
|
||||
resolution: {integrity: sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==}
|
||||
dependencies:
|
||||
lowercase-keys: 2.0.0
|
||||
dev: false
|
||||
|
||||
/restore-cursor/2.0.0:
|
||||
resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==}
|
||||
engines: {node: '>=4'}
|
||||
|
@ -11311,6 +11070,7 @@ packages:
|
|||
/semver/5.7.1:
|
||||
resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/semver/6.3.0:
|
||||
resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
|
||||
|
@ -11784,13 +11544,6 @@ packages:
|
|||
- encoding
|
||||
dev: true
|
||||
|
||||
/systeminformation/5.11.14:
|
||||
resolution: {integrity: sha512-m8CJx3fIhKohanB0ExTk5q53uI1J0g5B09p77kU+KxnxRVpADVqTAwCg1PFelqKsj4LHd+qmVnumb511Hg4xow==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android]
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/tailwindcss/3.0.24:
|
||||
resolution: {integrity: sha512-H3uMmZNWzG6aqmg9q07ZIRNIawoiEcNFKDfL+YzOPuPsXuDXxJxB9icqzLgdzKNwjG3SAro2h9SYav8ewXNgig==}
|
||||
engines: {node: '>=12.13.0'}
|
||||
|
@ -12207,7 +11960,7 @@ packages:
|
|||
dependencies:
|
||||
'@types/glob': 7.2.0
|
||||
'@types/node': 17.0.31
|
||||
'@types/semver': 7.3.10
|
||||
'@types/semver': 7.3.12
|
||||
class-validator: 0.13.2
|
||||
glob: 7.2.0
|
||||
graphql: 15.8.0
|
||||
|
@ -12920,6 +12673,10 @@ packages:
|
|||
resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==}
|
||||
dev: false
|
||||
|
||||
/zod/3.19.1:
|
||||
resolution: {integrity: sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==}
|
||||
dev: false
|
||||
|
||||
/zustand/3.7.2_react@18.1.0:
|
||||
resolution: {integrity: sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==}
|
||||
engines: {node: '>=12.7.0'}
|
||||
|
|
196
scripts/app.sh
196
scripts/app.sh
|
@ -2,49 +2,32 @@
|
|||
# Required Notice: Copyright
|
||||
# Umbrel (https://umbrel.com)
|
||||
|
||||
echo "Starting app script"
|
||||
|
||||
source "${BASH_SOURCE%/*}/common.sh"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# use greadlink instead of readlink on osx
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
rdlk=greadlink
|
||||
else
|
||||
rdlk=readlink
|
||||
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)
|
||||
|
||||
# Override vars with values from settings.json
|
||||
if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
|
||||
# If storagePath is set in settings.json, use it
|
||||
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)" != "null" ]]; then
|
||||
STORAGE_PATH="$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)"
|
||||
fi
|
||||
fi
|
||||
|
||||
ROOT_FOLDER="$($rdlk -f $(dirname "${BASH_SOURCE[0]}")/..)"
|
||||
REPO_ID="$(echo -n "https://github.com/meienberger/runtipi-appstore" | sha256sum | awk '{print $1}')"
|
||||
STATE_FOLDER="${ROOT_FOLDER}/state"
|
||||
|
||||
show_help() {
|
||||
cat <<EOF
|
||||
app 0.0.1
|
||||
|
||||
CLI for managing Tipi apps
|
||||
|
||||
Usage: app <command> <app> [<arguments>]
|
||||
|
||||
Commands:
|
||||
install Pulls down images for an app and starts it
|
||||
uninstall Removes images and destroys all data for an app
|
||||
stop Stops an installed app
|
||||
start Starts an installed app
|
||||
compose Passes all arguments to docker-compose
|
||||
ls-installed Lists installed apps
|
||||
EOF
|
||||
}
|
||||
|
||||
# Get field from json file
|
||||
function get_json_field() {
|
||||
local json_file="$1"
|
||||
local field="$2"
|
||||
|
||||
echo $(jq -r ".${field}" "${json_file}")
|
||||
}
|
||||
|
||||
list_installed_apps() {
|
||||
str=$(get_json_field ${STATE_FOLDER}/apps.json installed)
|
||||
echo $str
|
||||
}
|
||||
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=""
|
||||
|
@ -52,31 +35,10 @@ else
|
|||
command="$1"
|
||||
fi
|
||||
|
||||
# Lists installed apps
|
||||
if [[ "$command" = "ls-installed" ]]; then
|
||||
list_installed_apps
|
||||
|
||||
exit
|
||||
fi
|
||||
|
||||
if [ -z ${2+x} ]; then
|
||||
show_help
|
||||
exit 1
|
||||
else
|
||||
|
||||
app="$2"
|
||||
root_folder_host="${3:-$ROOT_FOLDER}"
|
||||
repo_id="${4:-$REPO_ID}"
|
||||
|
||||
if [[ -z "${repo_id}" ]]; then
|
||||
echo "Error: Repo id not provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${root_folder_host}" ]]; then
|
||||
echo "Error: Root folder not provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
app_dir="${ROOT_FOLDER}/apps/${app}"
|
||||
|
||||
|
@ -84,36 +46,35 @@ else
|
|||
# copy from repo
|
||||
echo "Copying app from repo"
|
||||
mkdir -p "${app_dir}"
|
||||
cp -r "${ROOT_FOLDER}/repos/${repo_id}/apps/${app}"/* "${app_dir}"
|
||||
cp -r "${ROOT_FOLDER}/repos/${REPO_ID}/apps/${app}"/* "${app_dir}"
|
||||
fi
|
||||
|
||||
app_data_dir="${ROOT_FOLDER}/app-data/${app}"
|
||||
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
|
||||
|
||||
if [ -z ${3+x} ]; then
|
||||
args=""
|
||||
else
|
||||
args="${@:3}"
|
||||
args="${*:3}"
|
||||
fi
|
||||
|
||||
compose() {
|
||||
local app="${1}"
|
||||
shift
|
||||
|
||||
local architecture="$(uname -m)"
|
||||
arch=$(uname -m)
|
||||
local architecture="${arch}"
|
||||
|
||||
if [[ "$architecture" == "aarch64" ]]; then
|
||||
architecture="arm64"
|
||||
fi
|
||||
|
||||
# App data folder
|
||||
local env_file="${ROOT_FOLDER}/.env"
|
||||
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
|
||||
|
@ -121,19 +82,23 @@ compose() {
|
|||
app_compose_file="${app_dir}/docker-compose.arm.yml"
|
||||
fi
|
||||
|
||||
local common_compose_file="${ROOT_FOLDER}/repos/${repo_id}/apps/docker-compose.common.yml"
|
||||
# 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"
|
||||
|
||||
# Vars to use in compose file
|
||||
export APP_DATA_DIR="${root_folder_host}/app-data/${app}"
|
||||
export APP_DIR="${app_dir}"
|
||||
export ROOT_FOLDER_HOST="${root_folder_host}"
|
||||
export ROOT_FOLDER="${ROOT_FOLDER}"
|
||||
export APP_DATA_DIR="${STORAGE_PATH}/app-data/${app}"
|
||||
export ROOT_FOLDER_HOST="${ROOT_FOLDER_HOST}"
|
||||
|
||||
# Docker-compose does not support multiple env files
|
||||
# --env-file "${env_file}" \
|
||||
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 "${ROOT_FOLDER}/app-data/${app}/app.env" \
|
||||
docker compose \
|
||||
--env-file "${app_data_dir}/app.env" \
|
||||
--project-name "${app}" \
|
||||
--file "${app_compose_file}" \
|
||||
--file "${common_compose_file}" \
|
||||
|
@ -142,7 +107,13 @@ compose() {
|
|||
|
||||
# Install new app
|
||||
if [[ "$command" = "install" ]]; then
|
||||
compose "${app}" pull
|
||||
# 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
|
||||
|
||||
# Copy default data dir to app data dir if it exists
|
||||
if [[ -d "${app_dir}/data" ]]; then
|
||||
|
@ -152,20 +123,30 @@ if [[ "$command" = "install" ]]; then
|
|||
# Remove all .gitkeep files from app data dir
|
||||
find "${app_data_dir}" -name ".gitkeep" -exec rm -f {} \;
|
||||
|
||||
chown -R "1000:1000" "${app_data_dir}"
|
||||
chmod -R a+rwx "${app_data_dir}"
|
||||
|
||||
compose "${app}" up -d
|
||||
exit
|
||||
if ! compose "${app}" up -d; then
|
||||
write_log "Failed to start app ${app}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Removes images and destroys all data for an app
|
||||
if [[ "$command" = "uninstall" ]]; then
|
||||
echo "Removing images for app ${app}..."
|
||||
write_log "Removing images for app ${app}..."
|
||||
|
||||
compose "${app}" up --detach
|
||||
compose "${app}" down --rmi all --remove-orphans
|
||||
if ! compose "${app}" up --detach; then
|
||||
write_log "Failed to uninstall app ${app}"
|
||||
exit 1
|
||||
fi
|
||||
if ! compose "${app}" down --rmi all --remove-orphans; then
|
||||
write_log "Failed to uninstall app ${app}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deleting app data for app ${app}..."
|
||||
write_log "Deleting app data for app ${app}..."
|
||||
if [[ -d "${app_data_dir}" ]]; then
|
||||
rm -rf "${app_data_dir}"
|
||||
fi
|
||||
|
@ -174,14 +155,21 @@ if [[ "$command" = "uninstall" ]]; then
|
|||
rm -rf "${app_dir}"
|
||||
fi
|
||||
|
||||
echo "Successfully uninstalled app ${app}"
|
||||
write_log "Successfully uninstalled app ${app}"
|
||||
exit
|
||||
fi
|
||||
|
||||
# Update an app
|
||||
if [[ "$command" = "update" ]]; then
|
||||
compose "${app}" up --detach
|
||||
compose "${app}" down --rmi all --remove-orphans
|
||||
if ! compose "${app}" up --detach; then
|
||||
write_log "Failed to update app ${app}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! compose "${app}" down --rmi all --remove-orphans; then
|
||||
write_log "Failed to update app ${app}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove app
|
||||
if [[ -d "${app_dir}" ]]; then
|
||||
|
@ -189,33 +177,41 @@ if [[ "$command" = "update" ]]; then
|
|||
fi
|
||||
|
||||
# Copy app from repo
|
||||
cp -r "${ROOT_FOLDER}/repos/${repo_id}/apps/${app}" "${app_dir}"
|
||||
cp -r "${ROOT_FOLDER}/repos/${REPO_ID}/apps/${app}" "${app_dir}"
|
||||
|
||||
compose "${app}" pull
|
||||
exit
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Stops an installed app
|
||||
if [[ "$command" = "stop" ]]; then
|
||||
echo "Stopping app ${app}..."
|
||||
compose "${app}" rm --force --stop
|
||||
exit
|
||||
write_log "Stopping app ${app}..."
|
||||
|
||||
if ! compose "${app}" rm --force --stop; then
|
||||
write_log "Failed to stop app ${app}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Starts an installed app
|
||||
if [[ "$command" = "start" ]]; then
|
||||
echo "Starting app ${app}..."
|
||||
compose "${app}" up --detach
|
||||
exit
|
||||
write_log "Starting app ${app}..."
|
||||
if ! compose "${app}" up --detach; then
|
||||
write_log "Failed to start app ${app}"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Passes all arguments to docker-compose
|
||||
# Passes all arguments to Docker Compose
|
||||
if [[ "$command" = "compose" ]]; then
|
||||
compose "${app}" ${args}
|
||||
exit
|
||||
if ! compose "${app}" "${args}"; then
|
||||
write_log "Failed to run compose command for app ${app}"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# If we get here it means no valid command was supplied
|
||||
# Show help and exit
|
||||
show_help
|
||||
exit 1
|
||||
|
|
90
scripts/common.sh
Normal file
90
scripts/common.sh
Normal file
|
@ -0,0 +1,90 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# 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 derive_entropy() {
|
||||
SEED_FILE="${STATE_FOLDER}/seed"
|
||||
identifier="${1}"
|
||||
tipi_seed=$(cat "${SEED_FILE}") || true
|
||||
|
||||
if [[ -z "$tipi_seed" ]] || [[ -z "$identifier" ]]; then
|
||||
echo >&2 "Missing derivation parameter, this is unsafe, 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
|
||||
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..."
|
||||
|
||||
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() {
|
||||
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
|
||||
kill -9 $pid
|
||||
done
|
||||
else
|
||||
kill -9 $watcher_pid
|
||||
fi
|
||||
fi
|
||||
}
|
|
@ -1,16 +1,33 @@
|
|||
#!/usr/bin/env bash
|
||||
ROOT_FOLDER="$(readlink -f "$(dirname "${BASH_SOURCE[0]}")"/..)"
|
||||
|
||||
echo
|
||||
echo "======================================"
|
||||
if [[ -f "${ROOT_FOLDER}/state/configured" ]]; then
|
||||
echo "=========== RECONFIGURING ============"
|
||||
else
|
||||
echo "============ CONFIGURING ============="
|
||||
fi
|
||||
echo "=============== TIPI ================="
|
||||
echo "======================================"
|
||||
echo
|
||||
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 apt-get update
|
||||
sudo apt-get install -y "${dependency}"
|
||||
return 0
|
||||
elif [[ "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
|
||||
sudo apt-get update
|
||||
sudo 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
|
||||
sudo pacman -Sy --noconfirm "${dependency}"
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function install_docker() {
|
||||
local os="${1}"
|
||||
|
@ -51,61 +68,74 @@ function install_docker() {
|
|||
sudo systemctl enable docker
|
||||
return 0
|
||||
elif [[ "${os}" == "arch" ]]; then
|
||||
sudo pacman -Sy --noconfirm docker
|
||||
sudo pacman -Sy --noconfirm docker docker-compose
|
||||
sudo systemctl start docker.service
|
||||
sudo systemctl enable docker.service
|
||||
|
||||
if ! command -v crontab >/dev/null; then
|
||||
sudo pacman -Sy --noconfirm cronie
|
||||
systemctl enable --now cronie.service
|
||||
fi
|
||||
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function install_jq() {
|
||||
function update_docker() {
|
||||
local os="${1}"
|
||||
echo "Installing jq for os ${os}" >/dev/tty
|
||||
echo "Updating Docker for os ${os}" >/dev/tty
|
||||
|
||||
if [[ "${os}" == "debian" || "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
|
||||
if [[ "${os}" == "debian" ]]; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y jq
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
return 0
|
||||
elif [[ "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
return 0
|
||||
elif [[ "${os}" == "centos" ]]; then
|
||||
sudo yum install -y jq
|
||||
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 jq
|
||||
sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
return 0
|
||||
elif [[ "${os}" == "arch" ]]; then
|
||||
sudo pacman -Sy --noconfirm jq
|
||||
sudo pacman -Sy --noconfirm docker docker-compose
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
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 '"')"
|
||||
|
||||
if command -v docker >/dev/null; then
|
||||
echo "Docker is already installed"
|
||||
else
|
||||
install_docker "${OS}"
|
||||
echo "Docker is already installed, ensuring Docker is fully up to date"
|
||||
|
||||
update_docker "${OS}"
|
||||
docker_result=$?
|
||||
|
||||
if [[ docker_result -eq 0 ]]; then
|
||||
echo "docker installed"
|
||||
echo "Docker is fully up to date"
|
||||
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"
|
||||
echo "Docker is fully up to date"
|
||||
else
|
||||
echo "Your system ${SUB_OS} is not supported please update Docker manually"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
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
|
||||
|
@ -113,27 +143,31 @@ else
|
|||
fi
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose >/dev/null; then
|
||||
sudo curl -L "https://github.com/docker/compose/releases/download/v2.3.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
fi
|
||||
function check_dependency_and_install() {
|
||||
local dependency="${1}"
|
||||
|
||||
if ! command -v jq >/dev/null; then
|
||||
install_jq "${OS}"
|
||||
jq_result=$?
|
||||
if ! command -v fswatch >/dev/null; then
|
||||
echo "Installing ${dependency}"
|
||||
install_generic "${dependency}" "${OS}"
|
||||
install_result=$?
|
||||
|
||||
if [[ jq_result -eq 0 ]]; then
|
||||
echo "jq installed"
|
||||
else
|
||||
echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}"
|
||||
install_jq "${SUB_OS}"
|
||||
jq_sub_result=$?
|
||||
|
||||
if [[ jq_sub_result -eq 0 ]]; then
|
||||
echo "jq installed"
|
||||
if [[ install_result -eq 0 ]]; then
|
||||
echo "${dependency} installed"
|
||||
else
|
||||
echo "Your system ${SUB_OS} is not supported please install jq manually"
|
||||
exit 1
|
||||
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
|
||||
fi
|
||||
}
|
||||
|
||||
check_dependency_and_install "jq"
|
||||
check_dependency_and_install "fswatch"
|
||||
check_dependency_and_install "openssl"
|
||||
|
|
3
scripts/deploy/release-rc.sh
Executable file
3
scripts/deploy/release-rc.sh
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t meienberger/runtipi:rc-"$(npm run version --silent)" . --push
|
|
@ -1,33 +1,15 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# use greadlink instead of readlink on osx
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
rdlk=greadlink
|
||||
else
|
||||
rdlk=readlink
|
||||
fi
|
||||
source "${BASH_SOURCE%/*}/common.sh"
|
||||
|
||||
ROOT_FOLDER="$($rdlk -f $(dirname "${BASH_SOURCE[0]}")/..)"
|
||||
ensure_pwd
|
||||
|
||||
show_help() {
|
||||
cat <<EOF
|
||||
app 0.0.1
|
||||
|
||||
CLI for managing Tipi apps
|
||||
|
||||
Usage: git <command> <repo> [<arguments>]
|
||||
|
||||
Commands:
|
||||
clone Clones a repo in the repo folder
|
||||
update Updates the repo folder
|
||||
get_hash Gets the local hash of the repo
|
||||
EOF
|
||||
}
|
||||
ROOT_FOLDER="${PWD}"
|
||||
|
||||
# Get a static hash based on the repo url
|
||||
function get_hash() {
|
||||
url="${1}"
|
||||
echo $(echo -n "${url}" | sha256sum | awk '{print $1}')
|
||||
echo -n "${url}" | sha256sum | awk '{print $1}'
|
||||
}
|
||||
|
||||
if [ -z ${1+x} ]; then
|
||||
|
@ -41,17 +23,22 @@ if [[ "$command" = "clone" ]]; then
|
|||
repo="$2"
|
||||
repo_hash=$(get_hash "${repo}")
|
||||
|
||||
echo "Cloning ${repo} to ${ROOT_FOLDER}/repos/${repo_hash}"
|
||||
write_log "Cloning ${repo} to ${ROOT_FOLDER}/repos/${repo_hash}"
|
||||
repo_dir="${ROOT_FOLDER}/repos/${repo_hash}"
|
||||
if [ -d "${repo_dir}" ]; then
|
||||
echo "Repo already exists"
|
||||
write_log "Repo already exists"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Cloning ${repo} to ${repo_dir}"
|
||||
git clone "${repo}" "${repo_dir}"
|
||||
echo "Done"
|
||||
exit
|
||||
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
|
||||
|
@ -59,20 +46,28 @@ 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
|
||||
echo "Repo does not exist"
|
||||
exit 0
|
||||
write_log "Repo does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating ${repo} in ${repo_hash}"
|
||||
cd "${repo_dir}"
|
||||
git pull origin master
|
||||
echo "Done"
|
||||
exit
|
||||
write_log "Updating ${repo} in ${repo_hash}"
|
||||
cd "${repo_dir}" || exit
|
||||
|
||||
if ! git pull origin master; 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"
|
||||
echo $(get_hash "${repo}")
|
||||
get_hash "${repo}"
|
||||
exit
|
||||
fi
|
||||
|
|
11
scripts/start-dev.sh
Executable file
11
scripts/start-dev.sh
Executable file
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
source "${BASH_SOURCE%/*}/common.sh"
|
||||
|
||||
ROOT_FOLDER="${PWD}"
|
||||
|
||||
kill_watcher
|
||||
chmod -R a+rwx "${ROOT_FOLDER}/state/events"
|
||||
chmod -R a+rwx "${ROOT_FOLDER}/state/system-info.json"
|
||||
"${ROOT_FOLDER}/scripts/watcher.sh" &
|
||||
|
||||
docker compose -f docker-compose.dev.yml --env-file "${ROOT_FOLDER}/.env.dev" up --build
|
258
scripts/start.sh
258
scripts/start.sh
|
@ -1,23 +1,61 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Required Notice: Copyright
|
||||
# Umbrel (https://umbrel.com)
|
||||
|
||||
set -e # Exit immediately if a command exits with a non-zero status.
|
||||
|
||||
# use greadlink instead of readlink on osx
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
readlink=greadlink
|
||||
else
|
||||
readlink=readlink
|
||||
fi
|
||||
source "${BASH_SOURCE%/*}/common.sh"
|
||||
|
||||
ROOT_FOLDER="${PWD}"
|
||||
|
||||
# Cleanup and ensure environment
|
||||
ensure_linux
|
||||
ensure_pwd
|
||||
ensure_root
|
||||
clean_logs
|
||||
|
||||
# Default variables
|
||||
NGINX_PORT=80
|
||||
NGINX_PORT_SSL=443
|
||||
PROXY_PORT=8080
|
||||
DOMAIN=tipi.localhost
|
||||
STATE_FOLDER="${ROOT_FOLDER}/state"
|
||||
SED_ROOT_FOLDER="$(echo "$ROOT_FOLDER" | sed 's/\//\\\//g')"
|
||||
DNS_IP=9.9.9.9 # Default to Quad9 DNS
|
||||
ARCHITECTURE="$(uname -m)"
|
||||
TZ="$(timedatectl | grep "Time zone" | awk '{print $3}' | sed 's/\//\\\//g' || Europe\/Berlin)"
|
||||
apps_repository="https://github.com/meienberger/runtipi-appstore"
|
||||
REPO_ID="$("${ROOT_FOLDER}"/scripts/git.sh get_hash ${apps_repository})"
|
||||
APPS_REPOSITORY_ESCAPED="$(echo ${apps_repository} | sed 's/\//\\\//g')"
|
||||
JWT_SECRET=$(derive_entropy "jwt")
|
||||
POSTGRES_PASSWORD=$(derive_entropy "postgres")
|
||||
TIPI_VERSION=$(get_json_field "${ROOT_FOLDER}/package.json" version)
|
||||
storage_path="${ROOT_FOLDER}"
|
||||
STORAGE_PATH_ESCAPED="$(echo "${storage_path}" | sed 's/\//\\\//g')"
|
||||
NETWORK_INTERFACE="$(ip route | grep default | awk '{print $5}' | uniq)"
|
||||
NETWORK_INTERFACE_COUNT=$(echo "$NETWORK_INTERFACE" | wc -l)
|
||||
|
||||
while [ -n "$1" ]; do # while loop starts
|
||||
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)"
|
||||
|
||||
if [[ "$ARCHITECTURE" == "aarch64" ]]; then
|
||||
ARCHITECTURE="arm64"
|
||||
fi
|
||||
|
||||
# Parse arguments
|
||||
while [ -n "$1" ]; do
|
||||
case "$1" in
|
||||
--rc) rc="true" ;;
|
||||
--ci) ci="true" ;;
|
||||
|
@ -43,17 +81,6 @@ while [ -n "$1" ]; do # while loop starts
|
|||
fi
|
||||
shift
|
||||
;;
|
||||
--proxy-port)
|
||||
proxy_port="$2"
|
||||
|
||||
if [[ "${proxy_port}" =~ ^[0-9]+$ ]]; then
|
||||
PROXY_PORT="${proxy_port}"
|
||||
else
|
||||
echo "--proxy-port must be a number"
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
--domain)
|
||||
domain="$2"
|
||||
|
||||
|
@ -65,6 +92,17 @@ while [ -n "$1" ]; do # while loop starts
|
|||
fi
|
||||
shift
|
||||
;;
|
||||
--listen-ip)
|
||||
listen_ip="$2"
|
||||
|
||||
if [[ "${listen_ip}" =~ ^[a-fA-F0-9.:]+$ ]]; then
|
||||
INTERNAL_IP="${listen_ip}"
|
||||
else
|
||||
echo "--listen-ip must be a valid IP address"
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift # The double dash makes them parameters
|
||||
break
|
||||
|
@ -74,107 +112,27 @@ while [ -n "$1" ]; do # while loop starts
|
|||
shift
|
||||
done
|
||||
|
||||
# Ensure BASH_SOURCE is ./scripts/start.sh
|
||||
if [[ $(basename $(pwd)) != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
|
||||
echo "Please make sure this script is executed from runtipi/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check we are on linux
|
||||
if [[ "$(uname)" != "Linux" ]]; then
|
||||
echo "Tipi only works on Linux"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# If port is not 80 and domain is not tipi.localhost, we exit
|
||||
if [[ "${NGINX_PORT}" != "80" ]] && [[ "${DOMAIN}" != "tipi.localhost" ]]; then
|
||||
echo "Using a custom domain with a custom port is not supported"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT_FOLDER="$($readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
|
||||
STATE_FOLDER="${ROOT_FOLDER}/state"
|
||||
SED_ROOT_FOLDER="$(echo $ROOT_FOLDER | sed 's/\//\\\//g')"
|
||||
|
||||
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)"
|
||||
DNS_IP=9.9.9.9 # Default to Quad9 DNS
|
||||
ARCHITECTURE="$(uname -m)"
|
||||
TZ="$(timedatectl | grep "Time zone" | awk '{print $3}' | sed 's/\//\\\//g' || Europe\/Berlin)"
|
||||
APPS_REPOSITORY="https://github.com/meienberger/runtipi-appstore"
|
||||
REPO_ID="$(${ROOT_FOLDER}/scripts/git.sh get_hash ${APPS_REPOSITORY})"
|
||||
APPS_REPOSITORY_ESCAPED="$(echo ${APPS_REPOSITORY} | sed 's/\//\\\//g')"
|
||||
|
||||
if [[ "$ARCHITECTURE" == "aarch64" ]]; then
|
||||
ARCHITECTURE="arm64"
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
# Configure Tipi if it isn't already configured
|
||||
kill_watcher
|
||||
# Configure Tipi
|
||||
"${ROOT_FOLDER}/scripts/configure.sh"
|
||||
|
||||
# Get field from json file
|
||||
function get_json_field() {
|
||||
local json_file="$1"
|
||||
local field="$2"
|
||||
|
||||
echo $(jq -r ".${field}" "${json_file}")
|
||||
}
|
||||
|
||||
# Deterministically derives 128 bits of cryptographically secure entropy
|
||||
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 "Missing derivation parameter, this is unsafe, exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# We need `sed 's/^.* //'` to trim the "(stdin)= " prefix from some versions of openssl
|
||||
printf "%s" "${identifier}" | openssl dgst -sha256 -hmac "${tipi_seed}" | sed 's/^.* //'
|
||||
}
|
||||
chmod -R a+rwx "${ROOT_FOLDER}/state/system-info.json"
|
||||
"${ROOT_FOLDER}/scripts/watcher.sh" &
|
||||
|
||||
# Copy the config sample if it isn't here
|
||||
if [[ ! -f "${STATE_FOLDER}/apps.json" ]]; then
|
||||
cp "${ROOT_FOLDER}/templates/config-sample.json" "${STATE_FOLDER}/config.json"
|
||||
fi
|
||||
|
||||
# Get current dns from host
|
||||
if [[ -f "/etc/resolv.conf" ]]; then
|
||||
TEMP=$(grep -E -o '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' /etc/resolv.conf | head -n 1)
|
||||
fi
|
||||
|
||||
# Create seed file with cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1
|
||||
if [[ ! -f "${STATE_FOLDER}/seed" ]]; then
|
||||
echo "Generating seed..."
|
||||
cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1 >"${STATE_FOLDER}/seed"
|
||||
tr </dev/urandom -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1 >"${STATE_FOLDER}/seed"
|
||||
fi
|
||||
|
||||
export DOCKER_CLIENT_TIMEOUT=240
|
||||
|
@ -190,26 +148,54 @@ ENV_FILE=$(mktemp)
|
|||
# Copy template configs to intermediary configs
|
||||
[[ -f "$ROOT_FOLDER/templates/env-sample" ]] && cp "$ROOT_FOLDER/templates/env-sample" "$ENV_FILE"
|
||||
|
||||
JWT_SECRET=$(derive_entropy "jwt")
|
||||
POSTGRES_PASSWORD=$(derive_entropy "postgres")
|
||||
TIPI_VERSION=$(get_json_field "${ROOT_FOLDER}/package.json" version)
|
||||
# Override vars with values from settings.json
|
||||
if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
|
||||
|
||||
echo "Creating .env file with the following values:"
|
||||
echo " DOMAIN=${DOMAIN}"
|
||||
echo " INTERNAL_IP=${INTERNAL_IP}"
|
||||
echo " NGINX_PORT=${NGINX_PORT}"
|
||||
echo " NGINX_PORT_SSL=${NGINX_PORT_SSL}"
|
||||
echo " PROXY_PORT=${PROXY_PORT}"
|
||||
echo " DNS_IP=${DNS_IP}"
|
||||
echo " ARCHITECTURE=${ARCHITECTURE}"
|
||||
echo " TZ=${TZ}"
|
||||
echo " APPS_REPOSITORY=${APPS_REPOSITORY}"
|
||||
echo " REPO_ID=${REPO_ID}"
|
||||
echo " JWT_SECRET=<redacted>"
|
||||
echo " POSTGRES_PASSWORD=<redacted>"
|
||||
echo " TIPI_VERSION=${TIPI_VERSION}"
|
||||
echo " ROOT_FOLDER=${SED_ROOT_FOLDER}"
|
||||
echo " APPS_REPOSITORY=${APPS_REPOSITORY_ESCAPED}"
|
||||
# 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_ESCAPED="$(echo ${apps_repository} | sed 's/\//\\\//g')"
|
||||
fi
|
||||
|
||||
# If appsRepoId is set in settings.json, use it
|
||||
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoId)" != "null" ]]; then
|
||||
REPO_ID=$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoId)
|
||||
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 storagePath is set in settings.json, use it
|
||||
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)" != "null" ]]; then
|
||||
storage_path="$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)"
|
||||
STORAGE_PATH_ESCAPED="$(echo "${storage_path}" | sed 's/\//\\\//g')"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set array with all new values
|
||||
new_values="DOMAIN=${DOMAIN}\nDNS_IP=${DNS_IP}\nAPPS_REPOSITORY=${APPS_REPOSITORY_ESCAPED}\nREPO_ID=${REPO_ID}\nNGINX_PORT=${NGINX_PORT}\nNGINX_PORT_SSL=${NGINX_PORT_SSL}\nINTERNAL_IP=${INTERNAL_IP}\nSTORAGE_PATH=${STORAGE_PATH_ESCAPED}\nTZ=${TZ}\nJWT_SECRET=${JWT_SECRET}\nROOT_FOLDER=${SED_ROOT_FOLDER}\nTIPI_VERSION=${TIPI_VERSION}\nARCHITECTURE=${ARCHITECTURE}"
|
||||
write_log "Final values: \n${new_values}"
|
||||
|
||||
for template in ${ENV_FILE}; do
|
||||
sed -i "s/<dns_ip>/${DNS_IP}/g" "${template}"
|
||||
|
@ -221,11 +207,11 @@ for template in ${ENV_FILE}; do
|
|||
sed -i "s/<architecture>/${ARCHITECTURE}/g" "${template}"
|
||||
sed -i "s/<nginx_port>/${NGINX_PORT}/g" "${template}"
|
||||
sed -i "s/<nginx_port_ssl>/${NGINX_PORT_SSL}/g" "${template}"
|
||||
sed -i "s/<proxy_port>/${PROXY_PORT}/g" "${template}"
|
||||
sed -i "s/<postgres_password>/${POSTGRES_PASSWORD}/g" "${template}"
|
||||
sed -i "s/<apps_repo_id>/${REPO_ID}/g" "${template}"
|
||||
sed -i "s/<apps_repo_url>/${APPS_REPOSITORY_ESCAPED}/g" "${template}"
|
||||
sed -i "s/<domain>/${DOMAIN}/g" "${template}"
|
||||
sed -i "s/<storage_path>/${STORAGE_PATH_ESCAPED}/g" "${template}"
|
||||
done
|
||||
|
||||
mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
|
||||
|
@ -234,26 +220,20 @@ mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
|
|||
echo "Running system-info.sh..."
|
||||
bash "${ROOT_FOLDER}/scripts/system-info.sh"
|
||||
|
||||
# Add crontab to run system-info.sh every minute
|
||||
! (crontab -l | grep -q "${ROOT_FOLDER}/scripts/system-info.sh") && (
|
||||
crontab -l
|
||||
echo "* * * * * ${ROOT_FOLDER}/scripts/system-info.sh"
|
||||
) | crontab -
|
||||
|
||||
## Don't run if config-only
|
||||
if [[ ! $ci == "true" ]]; then
|
||||
|
||||
if [[ $rc == "true" ]]; then
|
||||
docker-compose -f docker-compose.rc.yml --env-file "${ROOT_FOLDER}/.env" pull
|
||||
# Run docker-compose
|
||||
docker-compose -f docker-compose.rc.yml --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --build || {
|
||||
docker compose -f docker-compose.rc.yml --env-file "${ROOT_FOLDER}/.env" pull
|
||||
# Run docker compose
|
||||
docker compose -f docker-compose.rc.yml --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --build || {
|
||||
echo "Failed to start containers"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
docker-compose --env-file "${ROOT_FOLDER}/.env" pull
|
||||
# Run docker-compose
|
||||
docker-compose --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --build || {
|
||||
docker compose --env-file "${ROOT_FOLDER}/.env" pull
|
||||
# Run docker compose
|
||||
docker compose --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --build || {
|
||||
echo "Failed to start containers"
|
||||
exit 1
|
||||
}
|
||||
|
|
|
@ -1,38 +1,28 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# use greadlink instead of readlink on osx
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
readlink=greadlink
|
||||
else
|
||||
readlink=readlink
|
||||
fi
|
||||
source "${BASH_SOURCE%/*}/common.sh"
|
||||
|
||||
if [[ $UID != 0 ]]; then
|
||||
echo "Tipi must be stopped as root"
|
||||
echo "Please re-run this script as"
|
||||
echo " sudo ./scripts/stop.sh"
|
||||
exit 1
|
||||
fi
|
||||
ensure_pwd
|
||||
ensure_root
|
||||
|
||||
ROOT_FOLDER="$($readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
|
||||
STATE_FOLDER="${ROOT_FOLDER}/state"
|
||||
|
||||
cd "$ROOT_FOLDER"
|
||||
ROOT_FOLDER="${PWD}"
|
||||
ENV_FILE="${ROOT_FOLDER}/.env"
|
||||
STORAGE_PATH=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep STORAGE_PATH | cut -d '=' -f2)
|
||||
|
||||
export DOCKER_CLIENT_TIMEOUT=240
|
||||
export COMPOSE_HTTP_TIMEOUT=240
|
||||
|
||||
# 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
|
||||
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 "${ROOT_FOLDER}/app-data/${app_name}" ]]; then
|
||||
if [[ -d "${STORAGE_PATH}/app-data/${app_name}" ]]; then
|
||||
echo "Stopping ${app_name}"
|
||||
"${ROOT_FOLDER}/scripts/app.sh" stop $app_name
|
||||
"${ROOT_FOLDER}/scripts/app.sh" stop "$app_name"
|
||||
fi
|
||||
done
|
||||
else
|
||||
|
@ -41,4 +31,4 @@ fi
|
|||
|
||||
echo "Stopping Docker services..."
|
||||
echo
|
||||
docker-compose down --remove-orphans --rmi local
|
||||
docker compose down --remove-orphans --rmi local
|
||||
|
|
|
@ -1,25 +1,34 @@
|
|||
#!/usr/bin/env bash
|
||||
set -e # Exit immediately if a command exits with a non-zero status.
|
||||
|
||||
ROOT_FOLDER="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
|
||||
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))
|
||||
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=$(free -b | grep Mem | awk '{print $2}')
|
||||
MEM_AVAILABLE_BYTES=$(free -b | grep Mem | awk '{print $7}')
|
||||
MEM_USED_BYTES=$(($MEM_TOTAL_BYTES - $MEM_AVAILABLE_BYTES))
|
||||
MEM_TOTAL_BYTES=$(($(grep </proc/meminfo MemTotal | awk '{print $2}') * 1024))
|
||||
MEM_AVAILABLE_BYTES=$(($(grep </proc/meminfo MemAvailable | awk '{print $2}') * 1024))
|
||||
MEM_USED_BYTES=$((MEM_TOTAL_BYTES - MEM_AVAILABLE_BYTES))
|
||||
|
||||
# Create temporary json file
|
||||
TEMP_JSON_FILE=$(mktemp)
|
||||
echo '{ "cpu": { "load": '"${CPU_LOAD_PERCENTAGE}"' }, "memory": { "total": '"${MEM_TOTAL_BYTES}"' , "used": '"${MEM_USED_BYTES}"', "available": '"${MEM_AVAILABLE_BYTES}"' }, "disk": { "total": '"${TOTAL_DISK_SPACE_BYTES}"' , "used": '"${USED_DISK_SPACE_BYTES}"', "available": '"${AVAILABLE_DISK_SPACE_BYTES}"' } }' >"${TEMP_JSON_FILE}"
|
||||
|
||||
# Write to state file
|
||||
echo "$(cat "${TEMP_JSON_FILE}")" >"${STATE_FOLDER}/system-info.json"
|
||||
cat "${TEMP_JSON_FILE}" >"${STATE_FOLDER}/system-info.json"
|
||||
|
|
33
scripts/system.sh
Executable file
33
scripts/system.sh
Executable file
|
@ -0,0 +1,33 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
source "${BASH_SOURCE%/*}/common.sh"
|
||||
ensure_pwd
|
||||
|
||||
ROOT_FOLDER="${PWD}"
|
||||
|
||||
if [ -z ${1+x} ]; then
|
||||
command=""
|
||||
else
|
||||
command="$1"
|
||||
fi
|
||||
|
||||
# Restart Tipi
|
||||
if [[ "$command" = "restart" ]]; then
|
||||
write_log "Restarting Tipi..."
|
||||
|
||||
scripts/stop.sh
|
||||
scripts/start.sh
|
||||
|
||||
exit
|
||||
fi
|
||||
|
||||
# Update Tipi
|
||||
if [[ "$command" = "update" ]]; then
|
||||
write_log "Updating Tipi..."
|
||||
|
||||
scripts/stop.sh
|
||||
git config --global --add safe.directory "${ROOT_FOLDER}"
|
||||
git pull origin master
|
||||
scripts/start.sh
|
||||
exit
|
||||
fi
|
|
@ -3,13 +3,13 @@
|
|||
|
||||
# Prompt to confirm
|
||||
echo "This will reset your system to factory defaults. Are you sure you want to do this? (y/n)"
|
||||
read confirm
|
||||
read -r confirm
|
||||
if [ "$confirm" != "y" ]; then
|
||||
echo "Aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT_FOLDER="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
|
||||
ROOT_FOLDER="$(readlink -f "$(dirname "${BASH_SOURCE[0]}")"/..)"
|
||||
|
||||
# Stop Tipi
|
||||
"${ROOT_FOLDER}/scripts/stop.sh"
|
||||
|
@ -25,5 +25,5 @@ rm -rf "${ROOT_FOLDER}/app-data"
|
|||
rm -rf "${ROOT_FOLDER}/data/postgres"
|
||||
mkdir -p "${ROOT_FOLDER}/app-data"
|
||||
|
||||
cd "$ROOT_FOLDER"
|
||||
cd "$ROOT_FOLDER" || echo ""
|
||||
"${ROOT_FOLDER}/scripts/start.sh"
|
||||
|
|
125
scripts/watcher.sh
Executable file
125
scripts/watcher.sh
Executable file
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
source "${BASH_SOURCE%/*}/common.sh"
|
||||
|
||||
ROOT_FOLDER="${PWD}"
|
||||
WATCH_FILE="${ROOT_FOLDER}/state/events"
|
||||
|
||||
function clean_events() {
|
||||
echo "Cleaning 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=$?
|
||||
|
||||
echo "Command ${command_path} exited with code ${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
|
||||
|
||||
echo "Unknown command ${command}"
|
||||
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
|
|
@ -12,6 +12,6 @@ JWT_SECRET=<jwt_secret>
|
|||
ROOT_FOLDER_HOST=<root_folder>
|
||||
NGINX_PORT=<nginx_port>
|
||||
NGINX_PORT_SSL=<nginx_port_ssl>
|
||||
PROXY_PORT=<proxy_port>
|
||||
POSTGRES_PASSWORD=<postgres_password>
|
||||
DOMAIN=<domain>
|
||||
DOMAIN=<domain>
|
||||
STORAGE_PATH=<storage_path>
|
Loading…
Add table
Reference in a new issue