Move system-api in a docker comntainer

This commit is contained in:
Nicolas Meienberger 2022-05-17 12:51:59 +02:00
parent 8d1b53c905
commit 4e03ca01f1
28 changed files with 204 additions and 166 deletions

View File

@ -25,10 +25,29 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
name: Get tag from VERSION file
id: meta
run: |
VERSION=$(cat VERSION)
TAG=${VERSION}
echo "::set-output name=tag::${TAG}"
-
name: Build and push dashboard
uses: docker/build-push-action@v2
with:
context: ./packages/dashboard
platforms: linux/amd64,linux/arm64
push: true
tags: meienberger/tipi-dashboard:latest
tags: meienberger/tipi-dashboard:latest,meienberger/tipi-dashboard:${{ steps.meta.outputs.TAG }}
cache-from: type=registry,ref=meienberger/tipi-dashboard:latest
cache-to: type=inline
-
name: Build and push api
uses: docker/build-push-action@v2
with:
context: ./packages/system-api
platforms: linux/amd64,linux/arm64
push: true
tags: meienberger/tipi-api:latest,meienberger/tipi-api:${{ steps.meta.outputs.TAG }}
cache-from: type=registry,ref=meienberger/tipi-api:latest
cache-to: type=inline

33
.github/workflows/verify-release.yml vendored Normal file
View File

@ -0,0 +1,33 @@
name: Verify release
on:
pull_request:
branches:
- master
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 2
- uses: technote-space/get-diff-action@v6
with:
FILES: |
VERSION
- name: Ensure env.MATCHED_FILES has VERSION in it
id: check-version
run: |
if [[ -z "${{ env.MATCHED_FILES }}" ]]; then
echo "::error::VERSION not modified"
exit 1
fi
if [[ ! "${{ env.MATCHED_FILES }}" =~ VERSION ]]; then
echo "::error::VERSION not modified"
exit 1
fi

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.1.2

View File

@ -1,6 +1,4 @@
packages:
- jq
- ufw
- coreutils
- git
- docker

View File

@ -1,6 +0,0 @@
---
- hosts: tipi
become: yes
tasks:
- import_tasks: ./tasks/common/packages.yml

View File

@ -3,12 +3,6 @@
update_cache: yes
upgrade: yes
- name: Install node 16
shell: curl -fsSL https://deb.nodesource.com/setup_16.x | bash -
- name: Install node
shell: apt-get install -y nodejs
- name: Install essential packages
package:
name: "{{ packages }}"
@ -26,37 +20,9 @@
line: "{{ username }} ALL=(ALL) NOPASSWD: ALL"
validate: "/usr/sbin/visudo -cf %s"
- name: Restart SSH daemon
service:
name: sshd
state: restarted
- name: Allow SSH in UFW
community.general.ufw:
rule: allow
port: 22
proto: tcp
- name: Allow port 80 in UFW
community.general.ufw:
rule: allow
port: 80
proto: tcp
- name: Allow port 443 in UFW
community.general.ufw:
rule: allow
port: 443
proto: tcp
- name: Allow ports for apps
community.general.ufw:
rule: allow
port: 3000:3001
proto: tcp
- name: Enable ufw daemon
service:
name: ufw
state: started
enabled: yes
- name: Create cron every minute running system-info.sh
cron:
name: "system-info"
user: "{{ username }}"
minute: "*/1"
command: "{{ playbook_dir }}/../scripts/system-info.sh"

View File

@ -1,40 +0,0 @@
- name: Install "pm2" package globally.
community.general.npm:
name: pm2
global: yes
- name: Install "pnpm" package globally.
community.general.npm:
name: pnpm
global: yes
- name: Run pm2 first time
shell: pm2 list
- name: Enable pm2 as a service
shell: sudo env PATH=$PATH:/usr/local/bin pm2 startup -u {{ username }}
- name: Install dependencies
shell: cd {{ playbook_dir }} && pnpm install
- name: Clean packages
shell: cd {{ playbook_dir }} && pnpm -r clean
- name: Build packages
become_user: "{{ username }}"
shell: cd {{ playbook_dir }} && pnpm -r build-prod
- name: Check if app is already running
become_user: "{{ username }}"
shell: pm2 status system-api
register: pm2_result
- name: Start app
become_user: "{{ username }}"
shell: cd {{ playbook_dir }}/../packages/system-api && pm2 start npm --name "system-api" -- start
when: pm2_result.stdout.find("online") == -1
- name: Reload app
become_user: "{{ username }}"
shell: pm2 reload system-api
when: pm2_result.stdout.find("online") != -1

View File

@ -1,9 +1,15 @@
- name: Check if pm2 is installed
stat:
path: /usr/local/bin/pm2
register: pm2_status
- name: Check if app is already running
become_user: "{{ username }}"
shell: pm2 list
register: pm2_result
when: pm2_status.stat.exists
- name: Stop app
become_user: "{{ username }}"
shell: pm2 stop "system-api"
when: pm2_result.stdout.find("system-api") != -1
when: pm2_status.stat.exists && pm2_result.stdout.find("system-api") != -1

View File

@ -18,7 +18,7 @@ services:
api:
image: meienberger/tipi-api:latest
image: meienberger/tipi-api:${TIPI_VERSION}
container_name: api
ports:
- 3001:3001
@ -28,18 +28,22 @@ services:
- ${PWD}:/tipi
environment:
- INTERNAL_IP=${INTERNAL_IP}
- TIPI_VERSION=${TIPI_VERSION}
- JWT_SECRET=${JWT_SECRET}
networks:
tipi_main_network:
ipv4_address: 10.21.21.3
dashboard:
image: meienberger/tipi-dashboard:latest
image: meienberger/tipi-dashboard:${TIPI_VERSION}
container_name: dashboard
ports:
- 3000:3000
networks:
tipi_main_network:
ipv4_address: 10.21.21.4
environment:
- INTERNAL_IP=${INTERNAL_IP}
labels:
traefik.enable: true
traefik.http.routers.dashboard.rule: PathPrefix("/") # Host(`tipi.local`) &&

View File

@ -5,8 +5,7 @@
"scripts": {
"prepare": "husky install",
"act:test-install": "act --container-architecture linux/amd64 -j test-install",
"act:docker": "act --container-architecture linux/amd64 --secret-file github.secrets -j docker",
"docker:build": "docker build ./packages/system-api/ -t meienberger/tipi-api:latest && docker build ./packages/dashboard/ -t meienberger/tipi-dashboard:latest"
"act:docker": "act --container-architecture linux/amd64 --secret-file github.secrets -j docker"
},
"dependencies": {
"eslint": "^8.15.0",

View File

@ -2,9 +2,9 @@
const { NODE_ENV, INTERNAL_IP } = process.env;
const nextConfig = {
reactStrictMode: false,
reactStrictMode: true,
env: {
INTERNAL_IP: NODE_ENV === 'development' ? 'localhost' : INTERNAL_IP,
INTERNAL_IP: INTERNAL_IP,
},
};

View File

@ -1,6 +1,5 @@
import axios, { Method } from 'axios';
export const BASE_URL = 'http://localhost:3001';
import { useSytemStore } from '../state/systemStore';
interface IFetchParams {
endpoint: string;
@ -12,6 +11,9 @@ interface IFetchParams {
const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
const { endpoint, method = 'GET', params, data } = fetchParams;
const { getState } = useSytemStore;
const BASE_URL = `http://${getState().internalIp}:3001`;
const response = await axios.request<T & { error?: string }>({
method,
params,

View File

@ -1,8 +1,11 @@
import { BareFetcher } from 'swr';
import axios from 'axios';
import { BASE_URL } from './api';
import { useSytemStore } from '../state/systemStore';
const fetcher: BareFetcher<any> = (url: string) => {
const { getState } = useSytemStore;
const BASE_URL = `http://${getState().internalIp}:3001`;
return axios.get(url, { baseURL: BASE_URL, withCredentials: true }).then((res) => res.data);
};

View File

@ -3,6 +3,7 @@ import React from 'react';
import { FiExternalLink } from 'react-icons/fi';
import { AppConfig } from '../../../core/types';
import { useAppsStore } from '../../../state/appsStore';
import { useSytemStore } from '../../../state/systemStore';
import AppActions from '../components/AppActions';
import InstallModal from '../components/InstallModal';
import StopModal from '../components/StopModal';
@ -21,6 +22,7 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
const updateDisclosure = useDisclosure();
const { install, update, uninstall, stop, start, fetchApp } = useAppsStore();
const { internalIp } = useSytemStore();
const handleError = (error: unknown) => {
if (error instanceof Error) {
@ -86,7 +88,7 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
};
const handleOpen = () => {
window.open(`http://${process.env.INTERNAL_IP}:${app.port}`, '_blank');
window.open(`http://${internalIp}:${app.port}`, '_blank');
};
return (

View File

@ -1,6 +1,9 @@
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import useSWR, { BareFetcher } from 'swr';
import LoadingScreen from '../../../components/LoadingScreen';
import { useAuthStore } from '../../../state/authStore';
import { useSytemStore } from '../../../state/systemStore';
import Login from './Login';
import Onboarding from './Onboarding';
@ -8,9 +11,16 @@ interface IProps {
children: React.ReactNode;
}
const fetcher: BareFetcher<any> = (url: string) => {
return axios.get(url).then((res) => res.data);
};
const AuthWrapper: React.FC<IProps> = ({ children }) => {
const [initialLoad, setInitialLoad] = useState(true);
const { configured, user, me, fetchConfigured } = useAuthStore();
const { internalIp, setInternalIp } = useSytemStore();
const { data } = useSWR('/api/ip', fetcher);
useEffect(() => {
const fetchUser = async () => {
@ -19,8 +29,14 @@ const AuthWrapper: React.FC<IProps> = ({ children }) => {
setInitialLoad(false);
};
if (!user) fetchUser();
}, [fetchConfigured, me, user]);
if (!user && internalIp) fetchUser();
}, [fetchConfigured, internalIp, me, user]);
useEffect(() => {
if (data?.ip && !internalIp) {
setInternalIp(data.ip);
}
}, [data?.ip, internalIp, setInternalIp]);
if (initialLoad && !user) {
return <LoadingScreen />;

View File

@ -25,12 +25,12 @@ const Dashboard: React.FC = () => {
// Convert bytes to GB
const diskFree = Math.round(disk.available / 1024 / 1024 / 1024);
const diskSize = Math.round(disk.size / 1024 / 1024 / 1024);
const diskSize = Math.round(disk.total / 1024 / 1024 / 1024);
const diskUsed = diskSize - diskFree;
const percentUsed = Math.round((diskUsed / diskSize) * 100);
const memoryTotal = Math.round(memory?.total / 1024 / 1024 / 1024);
const memoryFree = Math.round(memory?.free / 1024 / 1024 / 1024);
const memoryFree = Math.round(memory?.available / 1024 / 1024 / 1024);
const percentUsedMemory = Math.round(((memoryTotal - memoryFree) / memoryTotal) * 100);
return (

View File

@ -0,0 +1,5 @@
export default function handler(_: any, res: any) {
const { INTERNAL_IP } = process.env;
res.status(200).json({ ip: INTERNAL_IP });
}

View File

@ -3,26 +3,25 @@ import { Text } from '@chakra-ui/react';
import useSWR from 'swr';
import Layout from '../components/Layout';
import fetcher from '../core/fetcher';
import Package from '../../package.json';
const Settings: NextPage = () => {
const { data: latestVersion } = useSWR<string>('/system/version/latest', fetcher);
const { data } = useSWR<{ current: string; latest: string }>('/system/version', fetcher);
const isLatest = latestVersion === `v${Package.version}`;
const isLatest = data?.latest === data?.current;
const renderUpdate = () => {
if (isLatest) {
return (
<Text fontSize="md" color="green.500">
Your Tipi install is up to date. Version {Package.version}
Your Tipi install is up to date. Version {data?.current}
</Text>
);
}
return (
<Text fontSize="md">
You are not using the latest version of Tipi. There is a new version ({latestVersion}) available. Visit{' '}
<a className="text-blue-600" target="_blank" rel="noreferrer" href={`https://github.com/meienberger/runtipi/releases/${latestVersion}`}>
You are not using the latest version of Tipi. There is a new version ({data?.latest}) available. Visit{' '}
<a className="text-blue-600" target="_blank" rel="noreferrer" href={`https://github.com/meienberger/runtipi/releases/v${data?.latest}`}>
Github
</a>{' '}
for update instructions.
@ -31,7 +30,7 @@ const Settings: NextPage = () => {
};
return (
<Layout loading={!latestVersion}>
<Layout loading={!data}>
<Text fontSize="3xl" className="font-bold">
Settings
</Text>

View File

@ -3,17 +3,21 @@ import api from '../core/api';
type Store = {
cpuLoad: number;
disk: { size: number; used: number; available: number };
memory: { total: number; used: number; free: number };
internalIp: string;
disk: { total: number; used: number; available: number };
memory: { total: number; used: number; available: number };
fetchDiskSpace: () => void;
fetchCpuLoad: () => void;
fetchMemoryLoad: () => void;
setInternalIp: (internalIp: string) => void;
};
export const useSytemStore = create<Store>((set) => ({
cpuLoad: 0,
memory: { total: 0, used: 0, free: 0 },
disk: { size: 0, used: 0, available: 0 },
internalIp: '',
setInternalIp: (internalIp: string) => set((state) => ({ ...state, internalIp })),
memory: { total: 0, used: 0, available: 0 },
disk: { total: 0, used: 0, available: 0 },
fetchDiskSpace: async () => {
const response = await api.fetch<any>({
endpoint: '/system/disk',

View File

@ -5,17 +5,19 @@ interface IConfig {
ROOT_FOLDER: string;
JWT_SECRET: string;
CLIENT_URLS: string[];
VERSION: string;
}
dotenv.config();
const { NODE_ENV = 'development', JWT_SECRET = '' } = process.env;
const { NODE_ENV = 'development', JWT_SECRET = '', INTERNAL_IP = '', TIPI_VERSION = '' } = process.env;
const config: IConfig = {
NODE_ENV,
ROOT_FOLDER: '/tipi',
JWT_SECRET,
CLIENT_URLS: ['http://locahost:3000', 'http://10.21.21.4', 'http://10.21.21.4:3000'],
CLIENT_URLS: ['http://localhost:3000', `http://${INTERNAL_IP}`, `http://${INTERNAL_IP}:3000`],
VERSION: TIPI_VERSION,
};
export default config;

View File

@ -1,34 +1,42 @@
import { Request, Response } from 'express';
import si from 'systeminformation';
import fetch from 'node-fetch';
import config from '../../config';
import TipiCache from '../../config/cache';
import { readJsonFile } from '../fs/fs.helpers';
type CpuData = {
load: number;
};
type DiskData = {
size: number;
total: number;
used: number;
available: number;
};
type MemoryData = {
total: number;
free: number;
available: number;
used: number;
};
type SystemInfo = {
cpu: CpuData;
disk: DiskData;
memory: MemoryData;
};
/**
*
* @param req
* @param res
*/
const getCpuInfo = async (req: Request, res: Response<CpuData>) => {
// const cpuInfo = await cpu.getCpuInfo();
const cpuLoad = await si.currentLoad();
const systemInfo: SystemInfo = readJsonFile('/state/system-info.json');
res.status(200).send({ load: cpuLoad.currentLoad });
const cpu = systemInfo.cpu;
res.status(200).send({ load: cpu.load });
};
/**
@ -37,19 +45,9 @@ const getCpuInfo = async (req: Request, res: Response<CpuData>) => {
* @param res
*/
const getDiskInfo = async (req: Request, res: Response<DiskData>) => {
const disk = await si.fsSize();
const systemInfo: SystemInfo = readJsonFile('/state/system-info.json');
const rootDisk = disk.find((item) => item.mount === '/');
if (!rootDisk) {
throw new Error('Could not find root disk');
}
const result: DiskData = {
size: rootDisk.size,
used: rootDisk.used,
available: rootDisk.available,
};
const result: DiskData = systemInfo.disk;
res.status(200).send(result);
};
@ -60,32 +58,24 @@ const getDiskInfo = async (req: Request, res: Response<DiskData>) => {
* @param res
*/
const getMemoryInfo = async (req: Request, res: Response<MemoryData>) => {
const memory = await si.mem();
const systemInfo: SystemInfo = readJsonFile('/state/system-info.json');
const result: MemoryData = {
total: memory.total,
free: memory.free,
used: memory.used,
};
const result: MemoryData = systemInfo.memory;
res.status(200).json(result);
};
const getLatestVersion = async (req: Request, res: Response<string>) => {
const getVersion = async (req: Request, res: Response<{ current: string; latest: string }>) => {
let version = TipiCache.get<string>('latestVersion');
console.log('CACHED', version);
if (!version) {
const response = await fetch('https://api.github.com/repos/meienberger/runtipi/releases/latest');
const json = (await response.json()) as { name: string };
TipiCache.set('latestVersion', json.name);
version = json.name;
version = json.name.replace('v', '');
}
console.log(version);
res.status(200).send(version);
res.status(200).send({ current: config.VERSION, latest: version });
};
export default { getCpuInfo, getDiskInfo, getMemoryInfo, getLatestVersion };
export default { getCpuInfo, getDiskInfo, getMemoryInfo, getVersion };

View File

@ -6,6 +6,6 @@ const router = Router();
router.route('/cpu').get(SystemController.getCpuInfo);
router.route('/disk').get(SystemController.getDiskInfo);
router.route('/memory').get(SystemController.getMemoryInfo);
router.route('/version/latest').get(SystemController.getLatestVersion);
router.route('/version').get(SystemController.getVersion);
export default router;

View File

@ -23,7 +23,22 @@ if (isProd) {
app.use(helmet());
}
app.use(cors({ credentials: true, origin: config.CLIENT_URLS }));
app.use(
cors({
credentials: true,
origin: function (origin, callback) {
// allow requests with no origin
if (!origin) return callback(null, true);
if (config.CLIENT_URLS.indexOf(origin) === -1) {
var message = "The CORS policy for this origin doesn't allow access from the particular origin.";
return callback(new Error(message), false);
}
return callback(null, true);
},
}),
);
// Get user from token
app.use((req, res, next) => {

View File

@ -23,8 +23,6 @@ if ! command -v ansible-playbook > /dev/null; then
sudo pip3 install ansible
fi
ansible-playbook ansible/setup.yml -i ansible/hosts -K -e username="$USERNAME"
# echo "Configuring permissions..."

View File

@ -90,20 +90,14 @@ echo "Generating config files..."
[[ -f "${ROOT_FOLDER}/packages/system-api/.env" ]] && rm -f "${ROOT_FOLDER}/packages/system-api/.env"
# Store paths to intermediary config files
ENV_FILE="$ROOT_FOLDER/templates/.env"
ENV_FILE_SYSTEM_API="$ROOT_FOLDER/templates/.env-api"
# Remove intermediary config files
[[ -f "$ENV_FILE" ]] && rm -f "$ENV_FILE"
[[ -f "$ENV_FILE_SYSTEM_API" ]] && rm -f "$ENV_FILE_SYSTEM_API"
ENV_FILE=$(mktemp)
# Copy template configs to intermediary configs
[[ -f "$ROOT_FOLDER/templates/env-sample" ]] && cp "$ROOT_FOLDER/templates/env-sample" "$ENV_FILE"
[[ -f "$ROOT_FOLDER/templates/env-api-sample" ]] && cp "$ROOT_FOLDER/templates/env-api-sample" "$ENV_FILE_SYSTEM_API"
JWT_SECRET=$(derive_entropy "jwt")
for template in "${ENV_FILE}" "${ENV_FILE_SYSTEM_API}"; do
for template in "${ENV_FILE}"; do
sed -i "s/<dns_ip>/${DNS_IP}/g" "${template}"
sed -i "s/<internal_ip>/${INTERNAL_IP}/g" "${template}"
sed -i "s/<puid>/${PUID}/g" "${template}"
@ -111,11 +105,16 @@ for template in "${ENV_FILE}" "${ENV_FILE_SYSTEM_API}"; do
sed -i "s/<tz>/${TZ}/g" "${template}"
sed -i "s/<jwt_secret>/${JWT_SECRET}/g" "${template}"
sed -i "s/<root_folder>/${SED_ROOT_FOLDER}/g" "${template}"
sed -i "s/<tipi_version>/$(cat "${ROOT_FOLDER}/VERSION")/g" "${template}"
done
mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
mv -f "$ENV_FILE_SYSTEM_API" "$ROOT_FOLDER/packages/system-api/.env"
# Run system-info.sh
echo "Running system-info.sh..."
bash "${ROOT_FOLDER}/scripts/system-info.sh"
# ansible-playbook ansible/start.yml -i ansible/hosts -K -e username="$USERNAME"
# Run docker-compose

25
scripts/system-info.sh Executable file
View File

@ -0,0 +1,25 @@
#!/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]}")/..)"
STATE_FOLDER="${ROOT_FOLDER}/state"
# Available disk space
TOTAL_DISK_SPACE_BYTES=$(df -P -B 1 / | tail -n 1 | awk '{print $2}')
AVAILABLE_DISK_SPACE_BYTES=$(df -P -B 1 / | tail -n 1 | awk '{print $4}')
USED_DISK_SPACE_BYTES=$(($TOTAL_DISK_SPACE_BYTES - $AVAILABLE_DISK_SPACE_BYTES))
# CPU info
CPU_LOAD_PERCENTAGE=$(top -bn1 | grep "Cpu(s)" | sed "s/.*, *\([0-9.]*\)%* id.*/\1/" | awk '{print 100 - $1}')
# Memory info
MEM_TOTAL_BYTES=$(cat /proc/meminfo | grep MemTotal | awk '{print $2}')
MEM_FREE_BYTES=$(cat /proc/meminfo | grep MemFree | awk '{print $2}')
MEM_USED_BYTES=$(($MEM_TOTAL_BYTES - $MEM_FREE_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_FREE_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"

View File

@ -1,4 +0,0 @@
ROOT_FOLDER=<root_folder>
JWT_SECRET=<jwt_secret>
INTERNAL_IP=<internal_ip>
ARCHITECTURE=<architecture>

View File

@ -7,3 +7,5 @@ PGID=<pgid>
INTERNAL_IP=<internal_ip>
DNS_IP=<dns_ip>
ARCHITECTURE=<architecture>
TIPI_VERSION=<tipi_version>
JWT_SECRET=<jwt_secret>