Merge pull request #223 from meienberger/release/0.7.0

Release/0.7.0
This commit is contained in:
Nicolas Meienberger 2022-10-10 18:50:40 +00:00 committed by GitHub
commit 7aabf0de7b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
89 changed files with 2795 additions and 1297 deletions

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

View 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",

View file

@ -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',
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}`;
};

View file

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

View file

@ -0,0 +1,3 @@
mutation Restart {
restart
}

View file

@ -0,0 +1,3 @@
mutation Update {
update
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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>
);
};

View file

@ -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 });
}
},
}));

View file

@ -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 })),
}));

View file

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

View file

@ -19,4 +19,7 @@ module.exports = {
'no-unused-vars': [1, { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-unused-vars': [1, { argsIgnorePattern: '^_' }],
},
globals: {
NodeJS: true,
},
};

View file

@ -1,7 +1,10 @@
node_modules/
dist/
.DS_Store
# testing
coverage/
logs/
sessions/
.vscode

View file

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

View 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;

View file

@ -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,
},
},
};

View file

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

View file

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

View file

@ -1 +0,0 @@
export { default } from './config';

View file

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

View file

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

View 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;

View 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();

View file

@ -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();
});
});

View file

@ -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();
});
});

View 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();
});
});

View file

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

View file

@ -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,
});

View file

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

View file

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

View file

@ -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();
}
};

View file

@ -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();
});
});
};

View file

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

View file

@ -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 () => {

View file

@ -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();
});

View file

@ -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);
});

View file

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

View file

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

View file

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

View file

@ -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}`);
}
};

View file

@ -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();
});
});

View file

@ -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();
});
});

View 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,
};

View file

@ -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();
}
}

View file

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

View file

@ -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);
}
};

View file

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

View file

@ -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();
});

View file

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

View file

@ -0,0 +1,3 @@
mutation {
restart
}

View file

@ -0,0 +1,3 @@
mutation {
update
}

View file

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

View file

@ -0,0 +1,17 @@
query {
systemInfo {
cpu {
load
}
memory {
total
available
used
}
disk {
total
available
used
}
}
}

View file

@ -0,0 +1,6 @@
query {
version {
current
latest
}
}

321
pnpm-lock.yaml generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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