[release] version 0.5.0-unstable2

This commit is contained in:
Yann Stepienik 2023-05-13 18:38:39 +01:00
parent 8b4d738c2e
commit c670456d47
27 changed files with 1576 additions and 135 deletions

View file

@ -173,6 +173,53 @@ function createTerminal(containerId) {
return new WebSocket(protocol + window.location.host + '/cosmos/api/servapps/' + containerId + '/terminal/new');
}
function createService(serviceData, onProgress) {
const requestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(serviceData)
};
return fetch('/cosmos/api/docker-service', requestOptions)
.then(response => {
if (!response.ok) {
throw new Error(response.statusText);
}
// The response body is a ReadableStream. This code reads the stream and passes chunks to the callback.
const reader = response.body.getReader();
// Read the stream and pass chunks to the callback as they arrive
return new ReadableStream({
start(controller) {
function read() {
return reader.read().then(({ done, value }) => {
if (done) {
controller.close();
return;
}
// Decode the UTF-8 text
let text = new TextDecoder().decode(value);
// Split by lines in case there are multiple lines in one chunk
let lines = text.split('\n');
for (let line of lines) {
if (line) {
// Call the progress callback
onProgress(line);
}
}
controller.enqueue(value);
return read();
});
}
return read();
}
});
});
}
export {
list,
get,
@ -192,4 +239,5 @@ export {
createVolume,
attachTerminal,
createTerminal,
createService,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 KiB

View file

@ -0,0 +1,23 @@
import React from 'react';
import { Button } from '@mui/material';
import { UploadOutlined } from '@ant-design/icons';
export default function UploadButtons({OnChange, accept}) {
return (
<div>
<input
accept={accept}
style={{ display: 'none' }}
id="contained-button-file"
multiple
type="file"
onChange={OnChange}
/>
<label htmlFor="contained-button-file">
<Button variant="contained" component="span" startIcon={<UploadOutlined />}>
Upload
</Button>
</label>
</div>
);
}

View file

@ -37,21 +37,26 @@ const a11yProps = (index) => {
};
};
const PrettyTabbedView = ({ tabs, isLoading }) => {
const PrettyTabbedView = ({ tabs, isLoading, currentTab, setCurrentTab }) => {
const [value, setValue] = useState(0);
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('md'));
if((currentTab != null && typeof currentTab === 'number') && value !== currentTab)
setValue(currentTab);
const handleChange = (event, newValue) => {
setValue(newValue);
setCurrentTab && setCurrentTab(newValue);
};
const handleSelectChange = (event) => {
setValue(event.target.value);
setCurrentTab && setCurrentTab(event.target.value);
};
return (
<Box display="flex" height="100%" flexDirection={isMobile ? 'column' : 'row'}>
{isMobile ? (
{(isMobile && !currentTab) ? (
<Select value={value} onChange={handleSelectChange} sx={{ minWidth: 120, marginBottom: '15px' }}>
{tabs.map((tab, index) => (
<MenuItem key={index} value={index}>
@ -70,7 +75,7 @@ const PrettyTabbedView = ({ tabs, isLoading }) => {
{tabs.map((tab, index) => (
<Tab
style={{fontWeight: !tab.children ? '1000' : '', }}
disabled={!tab.children} key={index}
disabled={tab.disabled || !tab.children} key={index}
label={tab.title} {...a11yProps(index)}
/>
))}

View file

@ -11,7 +11,7 @@ import { SearchOutlined } from '@ant-design/icons';
import { useTheme } from '@mui/material/styles';
import { Link } from 'react-router-dom';
const PrettyTableView = ({ getKey, data, columns, onRowClick, linkTo, buttons, fullWidth }) => {
const PrettyTableView = ({ getKey, data, columns, sort, onRowClick, linkTo, buttons, fullWidth }) => {
const [search, setSearch] = React.useState('');
const theme = useTheme();
const isDark = theme.palette.mode === 'dark';
@ -64,6 +64,10 @@ const PrettyTableView = ({ getKey, data, columns, onRowClick, linkTo, buttons, f
})
return found;
})
.sort((a, b) => {
if (!sort) return 0;
return sort(a, b);
})
.map((row, key) => (
<TableRow
key={getKey(row)}

View file

@ -33,7 +33,7 @@ import defaultport from '../../servapps/defaultport.json';
import * as API from '../../../api';
export function CosmosContainerPicker({formik, lockTarget, TargetContainer, onTargetChange}) {
export function CosmosContainerPicker({formik, nameOnly, lockTarget, TargetContainer, onTargetChange}) {
const [open, setOpen] = React.useState(false);
const [containers, setContainers] = React.useState([]);
const [hasPublicPorts, setHasPublicPorts] = React.useState(false);
@ -173,7 +173,7 @@ export function CosmosContainerPicker({formik, lockTarget, TargetContainer, onTa
const newTarget = formik.values[name];
React.useEffect(() => {
if(onTargetChange) {
onTargetChange(newTarget)
onTargetChange(newTarget, targetResult.container.replace("/", ""), targetResult)
}
}, [newTarget])
@ -219,62 +219,63 @@ export function CosmosContainerPicker({formik, lockTarget, TargetContainer, onTa
/>
)}
/>}
{!nameOnly && <>
{(portsOptions) ? (<>
<InputLabel htmlFor={name + "-port"}>Container Port</InputLabel>
<Autocomplete
className="px-2 my-2"
variant="outlined"
name={name + "-port"}
id={name + "-port"}
value={targetResult.port}
options={portsOptions.map((option) => (option))}
placeholder='Select a port'
freeSolo
filterOptions={(x) => x} // disable filtering
getOptionLabel={(option) => '' + option}
isOptionEqualToValue={(option, value) => {
return ('' + option) === value
}}
onChange={(event, newValue) => {
targetResult.port = newValue
formik.setFieldValue(name, getTarget())
}}
renderInput={(params) => <TextField {...params} />}
/>
{targetResult.port == '' && targetResult.port == 0 && <FormHelperText error id="standard-weight-helper-text-name-login">
Please select a port
</FormHelperText>}
</>) : ''}
{(portsOptions) ? (<>
<InputLabel htmlFor={name + "-protocol"}>Container Protocol (use HTTP if unsure)</InputLabel>
<TextField
type="text"
name={name + "-protocol"}
defaultValue={targetResult.protocol}
onChange={(event) => {
targetResult.protocol = event.target.value && event.target.value.toLowerCase()
formik.setFieldValue(name, getTarget())
}}
/>
</>) : ''}
{(portsOptions) ? (<>
<InputLabel htmlFor={name + "-port"}>Container Port</InputLabel>
<Autocomplete
className="px-2 my-2"
variant="outlined"
name={name + "-port"}
id={name + "-port"}
value={targetResult.port}
options={portsOptions.map((option) => (option))}
placeholder='Select a port'
freeSolo
filterOptions={(x) => x} // disable filtering
getOptionLabel={(option) => '' + option}
isOptionEqualToValue={(option, value) => {
return ('' + option) === value
}}
onChange={(event, newValue) => {
targetResult.port = newValue
formik.setFieldValue(name, getTarget())
}}
renderInput={(params) => <TextField {...params} />}
/>
{targetResult.port == '' && targetResult.port == 0 && <FormHelperText error id="standard-weight-helper-text-name-login">
Please select a port
</FormHelperText>}
</>) : ''}
{(portsOptions) ? (<>
<InputLabel htmlFor={name + "-protocol"}>Container Protocol (use HTTP if unsure)</InputLabel>
<TextField
type="text"
name={name + "-protocol"}
defaultValue={targetResult.protocol}
onChange={(event) => {
targetResult.protocol = event.target.value && event.target.value.toLowerCase()
formik.setFieldValue(name, getTarget())
}}
/>
</>) : ''}
<InputLabel htmlFor={name}>Result Target Preview</InputLabel>
<TextField
name={name}
placeholder={"This will be generated automatically"}
id={name}
value={formik.values[name]}
disabled={true}
/>
{formik.errors[name] && (
<FormHelperText error id="standard-weight-helper-text-name-login">
{formik.errors[name]}
</FormHelperText>
)}
<InputLabel htmlFor={name}>Result Target Preview</InputLabel>
<TextField
name={name}
placeholder={"This will be generated automatically"}
id={name}
value={formik.values[name]}
disabled={true}
/>
</>}
{formik.errors[name] && (
<FormHelperText error id="standard-weight-helper-text-name-login">
{formik.errors[name]}
</FormHelperText>
)}
</Stack>
</Grid>
);

View file

@ -3,7 +3,7 @@ import Back from "../../components/back";
import { Alert, Box, CircularProgress, Grid, Stack, useTheme } from "@mui/material";
import { useEffect, useState } from "react";
import * as API from "../../api";
import wallpaper from '../../assets/images/wallpaper.png';
import wallpaper from '../../assets/images/wallpaper.jpg';
import Grid2 from "@mui/material/Unstable_Grid2/Grid2";
import { getFaviconURL } from "../../utils/routes";
import { Link } from "react-router-dom";

View file

@ -0,0 +1,182 @@
// material-ui
import * as React from 'react';
import { Alert, Button, Stack, Typography } from '@mui/material';
import { WarningOutlined, PlusCircleOutlined, CopyOutlined, ExclamationCircleOutlined , SyncOutlined, UserOutlined, KeyOutlined, ArrowUpOutlined, FileZipOutlined } from '@ant-design/icons';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@mui/material/TextField';
import CircularProgress from '@mui/material/CircularProgress';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import * as API from '../../../api';
import MainCard from '../../../components/MainCard';
import IsLoggedIn from '../../../isLoggedIn';
import { useEffect, useState } from 'react';
import ResponsiveButton from '../../../components/responseiveButton';
import UploadButtons from '../../../components/fileUpload';
import NewDockerService from './newService';
import yaml from 'js-yaml';
function checkIsOnline() {
API.isOnline().then((res) => {
window.location.reload();
}).catch((err) => {
setTimeout(() => {
checkIsOnline();
}, 1000);
});
}
const preStyle = {
backgroundColor: '#000',
color: '#fff',
padding: '10px',
borderRadius: '5px',
overflow: 'auto',
maxHeight: '500px',
maxWidth: '100%',
width: '100%',
margin: '0',
position: 'relative',
fontSize: '12px',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
wordBreak: 'break-all',
lineHeight: '1.5',
boxShadow: '0 0 10px rgba(0,0,0,0.5)',
border: '1px solid rgba(255,255,255,0.1)',
boxSizing: 'border-box',
marginBottom: '10px',
marginTop: '10px',
marginLeft: '0',
marginRight: '0',
display: 'block',
textAlign: 'left',
verticalAlign: 'baseline',
opacity: '1',
}
const DockerComposeImport = () => {
const [step, setStep] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [openModal, setOpenModal] = useState(false);
const [dockerCompose, setDockerCompose] = useState('');
const [service, setService] = useState({});
useEffect(() => {
if(dockerCompose === '') {
return;
}
let newService = {};
let doc = yaml.load(dockerCompose);
// convert to the proper format
if(doc.services) {
Object.keys(doc.services).forEach((key) => {
// convert volumes
if(doc.services[key].volumes) {
let volumes = [];
doc.services[key].volumes.forEach((volume) => {
let volumeSplit = volume.split(':');
let volumeObj = {
Source: volumeSplit[0],
Target: volumeSplit[1],
Type: volume[0] === '/' ? 'bind' : 'volume',
};
volumes.push(volumeObj);
});
doc.services[key].volumes = volumes;
}
// convert expose
if(doc.services[key].expose) {
doc.services[key].expose = doc.services[key].expose.map((port) => {
return ''+port;
})
}
});
}
setService(doc);
console.log(doc);
}, [dockerCompose]);
return <>
<Dialog open={openModal} onClose={() => setOpenModal(false)}>
<DialogTitle>Import Docker Compose</DialogTitle>
<DialogContent>
<DialogContentText>
{step === 0 && <Stack spacing={2}>
<Alert severity="warning" icon={<WarningOutlined />}>
This is a highly experimental feature. It is recommended to use with caution.
</Alert>
<UploadButtons
accept='.yml,.yaml'
OnChange={(e) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (e) => {
setDockerCompose(e.target.result);
};
reader.readAsText(file);
}}
/>
<TextField
multiline
placeholder='Paste your docker-compose.yml here or use the file upload button.'
fullWidth
value={dockerCompose}
onChange={(e) => setDockerCompose(e.target.value)}
style={preStyle}
rows={20}></TextField>
</Stack>}
{step === 1 && <Stack spacing={2}>
<NewDockerService service={service} />
</Stack>}
</DialogContentText>
</DialogContent>
{!isLoading && <DialogActions>
<Button onClick={() => {
setOpenModal(false);
setStep(0);
setDockerCompose('');
}}>Close</Button>
<Button onClick={() => {
if(step === 0) {
setStep(1);
} else {
setStep(0);
}
}}>
{step === 0 && 'Next'}
{step === 1 && 'Back'}
</Button>
</DialogActions>}
</Dialog>
<ResponsiveButton
color="primary"
onClick={() => setOpenModal(true)}
variant="outlined"
startIcon={<ArrowUpOutlined />}
>
Import Docker Compose
</ResponsiveButton>
</>;
};
export default DockerComposeImport;

View file

@ -59,12 +59,6 @@ const ContainerIndex = () => {
title: 'Terminal',
children: <DockerTerminal refresh={refreshContainer} containerInfo={container} config={config}/>
},
{
title: 'Links',
children: <div>
<Alert severity="info">This feature is not yet implemented. It is planned for next version: 0.5.0</Alert>
</div>
},
{
title: 'Docker',
children: <DockerContainerSetup refresh={refreshContainer} containerInfo={container} config={config}/>
@ -74,7 +68,7 @@ const ContainerIndex = () => {
children: <NetworkContainerSetup refresh={refreshContainer} containerInfo={container} config={config}/>
},
{
title: 'Volumes',
title: 'Storage',
children: <VolumeContainerSetup refresh={refreshContainer} containerInfo={container} config={config}/>
},
]} />

View file

@ -10,14 +10,18 @@ import { LoadingButton } from '@mui/lab';
import PrettyTableView from '../../../components/tableView/prettyTableView';
import { NetworksColumns } from '../networks';
import NewNetworkButton from '../createNetwork';
import LinkContainersButton from '../linkContainersButton';
const NetworkContainerSetup = ({ config, containerInfo, refresh }) => {
const NetworkContainerSetup = ({ config, containerInfo, refresh, newContainer, OnChange, OnConnect, OnDisconnect }) => {
const [networks, setNetworks] = React.useState([]);
const theme = useTheme();
const isDark = theme.palette.mode === 'dark';
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const padding = isMobile ? '6px 4px' : '12px 10px';
const isForceSecure = containerInfo.Config.Labels.hasOwnProperty('cosmos-force-network-secured') &&
containerInfo.Config.Labels['cosmos-force-network-secured'] === 'true';
React.useEffect(() => {
API.docker.networkList().then((res) => {
setNetworks(res.data);
@ -25,25 +29,38 @@ const NetworkContainerSetup = ({ config, containerInfo, refresh }) => {
}, []);
const refreshAll = () => {
setNetworks(null);
refresh().then(() => {
if(refresh)
refresh().then(() => {
API.docker.networkList().then((res) => {
setNetworks(res.data);
});
});
else
API.docker.networkList().then((res) => {
setNetworks(res.data);
});
});
};
const connect = (network) => {
setNetworks(null);
return API.docker.attachNetwork(containerInfo.Name.replace('/', ''), network).then(() => {
if(!OnConnect) {
return API.docker.attachNetwork(containerInfo.Name.replace('/', ''), network).then(() => {
refreshAll();
});
} else {
OnConnect(network);
refreshAll();
});
}
}
const disconnect = (network) => {
return API.docker.detachNetwork(containerInfo.Name.replace('/', ''), network).then(() => {
if(!OnDisconnect) {
return API.docker.detachNetwork(containerInfo.Name.replace('/', ''), network).then(() => {
refreshAll();
});
} else {
OnDisconnect(network);
refreshAll();
});
}
}
return (<Stack spacing={2}>
@ -54,7 +71,7 @@ const NetworkContainerSetup = ({ config, containerInfo, refresh }) => {
return {
port: port.split('/')[0],
protocol: port.split('/')[1],
hostPort: containerInfo.NetworkSettings.Ports[port] ?
hostPort: containerInfo.NetworkSettings.Ports[port] && containerInfo.NetworkSettings.Ports[port][0] ?
containerInfo.NetworkSettings.Ports[port][0].HostPort : '',
};
})
@ -69,9 +86,11 @@ const NetworkContainerSetup = ({ config, containerInfo, refresh }) => {
if (unique.length !== ports.length) {
errors.submit = 'Ports must be unique';
}
OnChange && OnChange(values);
return errors;
}}
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
if(newContainer) return false;
setSubmitting(true);
const realvalues = {
portBindings: {},
@ -104,11 +123,16 @@ const NetworkContainerSetup = ({ config, containerInfo, refresh }) => {
<Stack spacing={2}>
<MainCard title={'Ports'}>
<Stack spacing={4}>
{containerInfo.State.Status !== 'running' && (
{containerInfo.State && containerInfo.State.Status !== 'running' && (
<Alert severity="warning" style={{ marginBottom: '0px' }}>
This container is not running. Editing any settings will cause the container to start again.
</Alert>
)}
{isForceSecure && (
<Alert severity="warning" style={{ marginBottom: '0px' }}>
This container is forced to be secured. You cannot expose any ports to the internet directly, please create a URL in Cosmos instead. You also cannot connect it to the Bridge network.
</Alert>
)}
<div>
{formik.values.ports.map((port, idx) => (
<Grid container key={idx}>
@ -195,7 +219,7 @@ const NetworkContainerSetup = ({ config, containerInfo, refresh }) => {
<FormHelperText error>{formik.errors.submit}</FormHelperText>
</Grid>
)}
<LoadingButton
{!newContainer && <LoadingButton
fullWidth
disableElevation
disabled={formik.errors.submit}
@ -206,7 +230,7 @@ const NetworkContainerSetup = ({ config, containerInfo, refresh }) => {
color="primary"
>
Update Ports
</LoadingButton>
</LoadingButton>}
</Stack>
</div>
</Stack>
@ -237,8 +261,15 @@ const NetworkContainerSetup = ({ config, containerInfo, refresh }) => {
{networks && <PrettyTableView
data={networks}
sort={(a, b) => a.Name > b.Name}
buttons={[
<NewNetworkButton refresh={refreshAll} />,
<LinkContainersButton
refresh={refreshAll}
originContainer={containerInfo.Name.replace('/', '')}
newContainer={newContainer}
OnConnect={OnConnect}
/>,
]}
onRowClick={() => { }}
getKey={(r) => r.Id}

View file

@ -0,0 +1,123 @@
import * as React from 'react';
import MainCard from '../../../components/MainCard';
import RestartModal from '../../config/users/restart';
import { Alert, Button, Chip, Divider, Stack, useMediaQuery } from '@mui/material';
import HostChip from '../../../components/hostChip';
import { RouteMode, RouteSecurity } from '../../../components/routeComponents';
import { getFaviconURL } from '../../../utils/routes';
import * as API from '../../../api';
import { AppstoreOutlined, ArrowUpOutlined, BulbTwoTone, CheckOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutlined, DownOutlined, LockOutlined, PlusCircleOutlined, SyncOutlined, UpOutlined } from "@ant-design/icons";
import IsLoggedIn from '../../../isLoggedIn';
import PrettyTabbedView from '../../../components/tabbedView/tabbedView';
import Back from '../../../components/back';
import { useParams } from 'react-router';
import ContainerOverview from './overview';
import Logs from './logs';
import DockerContainerSetup from './setup';
import NetworkContainerSetup from './network';
import VolumeContainerSetup from './volumes';
import DockerTerminal from './terminal';
import { Link } from 'react-router-dom';
const preStyle = {
backgroundColor: '#000',
color: '#fff',
padding: '10px',
borderRadius: '5px',
overflow: 'auto',
maxHeight: '500px',
maxWidth: '100%',
width: '100%',
margin: '0',
position: 'relative',
fontSize: '12px',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
wordBreak: 'break-all',
lineHeight: '1.5',
boxShadow: '0 0 10px rgba(0,0,0,0.5)',
border: '1px solid rgba(255,255,255,0.1)',
boxSizing: 'border-box',
marginBottom: '10px',
marginTop: '10px',
marginLeft: '0',
marginRight: '0',
display: 'block',
textAlign: 'left',
verticalAlign: 'baseline',
opacity: '1',
}
const NewDockerService = ({service}) => {
const { containerName } = useParams();
const [container, setContainer] = React.useState(null);
const [config, setConfig] = React.useState(null);
const [log, setLog] = React.useState([]);
const [isDone, setIsDone] = React.useState(false);
const [openModal, setOpenModal] = React.useState(false);
React.useEffect(() => {
// refreshContainer();
}, []);
const create = () => {
setLog([])
API.docker.createService(service, (newlog) => {
setLog((old) => [...old, newlog]);
if (newlog.includes('[OPERATION SUCCEEDED]')) {
setIsDone(true);
}
});
}
const needsRestart = service && service.service && service.service.some((c) => {
return c.routes && c.routes.length > 0;
});
return <div style={{ maxWidth: '1000px', width: '100%', margin: '', position: 'relative' }}>
<MainCard title="Create Service">
<RestartModal openModal={openModal} setOpenModal={setOpenModal} />
<Stack spacing={1}>
{!isDone && <Button
onClick={create}
variant="contained"
color="primary"
fullWidth
startIcon={<PlusCircleOutlined />}
>Create</Button>}
{isDone && <Stack spacing={1}>
<Alert severity="success">Service Created!</Alert>
{needsRestart && <Alert severity="warning">Cosmos needs to be restarted to apply changes to the URLs</Alert>}
{needsRestart &&
<Button
variant="contained"
color="primary"
fullWidth
startIcon={<SyncOutlined />}
onClick={() => {
setOpenModal(true);
}}
>Restart</Button>
}
<Link to={`/ui/servapps`}>
<Button
variant="outlined"
color="primary"
fullWidth
startIcon={<AppstoreOutlined />}
>Back to Servapps</Button>
</Link>
</Stack>}
<pre style={preStyle}>
{!log.length && JSON.stringify(service, false ,2)}
{log.map((l) => {
return <div>{l}</div>
})}
</pre>
</Stack>
</MainCard>
</div>;
}
export default NewDockerService;

View file

@ -0,0 +1,237 @@
import * as React from 'react';
import MainCard from '../../../components/MainCard';
import RestartModal from '../../config/users/restart';
import { Alert, Button, Chip, Divider, Stack, useMediaQuery } from '@mui/material';
import HostChip from '../../../components/hostChip';
import { RouteMode, RouteSecurity } from '../../../components/routeComponents';
import { getFaviconURL } from '../../../utils/routes';
import * as API from '../../../api';
import { ArrowLeftOutlined, ArrowRightOutlined, CheckOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutlined, DownOutlined, LockOutlined, UpOutlined } from "@ant-design/icons";
import IsLoggedIn from '../../../isLoggedIn';
import PrettyTabbedView from '../../../components/tabbedView/tabbedView';
import Back from '../../../components/back';
import { useParams } from 'react-router';
import ContainerOverview from './overview';
import Logs from './logs';
import DockerContainerSetup from './setup';
import NetworkContainerSetup from './network';
import VolumeContainerSetup from './volumes';
import DockerTerminal from './terminal';
import NewDockerService from './newService';
const NewDockerServiceForm = () => {
const [currentTab, setCurrentTab] = React.useState(0);
const [maxTab, setMaxTab] = React.useState(0);
const [containerInfo, setContainerInfo] = React.useState({
Name: '',
Config: {
Env: [],
Labels: {},
ExposedPorts: [],
Volumes: {},
Name: '',
Image: '',
Tty: true,
OpenStdin: true,
},
HostConfig: {
PortBindings: [],
RestartPolicy: {
Name: 'always',
},
Mounts: [],
},
NetworkSettings: {
Networks: {},
Ports: [],
},
});
let service = {
Services: {
container_name : {
container_name: containerInfo.Name,
image: containerInfo.Config.Image,
environment: containerInfo.Config.Env,
labels: containerInfo.Config.Labels,
expose: containerInfo.Config.ExposedPorts,
tty: containerInfo.Config.Tty,
stdin_open: containerInfo.Config.OpenStdin,
ports: containerInfo.HostConfig.PortBindings,
restart: containerInfo.HostConfig.RestartPolicy.Name,
volumes: containerInfo.HostConfig.Mounts,
networks: Object.keys(containerInfo.NetworkSettings.Networks).reduce((acc, cur) => {
acc[cur] = {};
return acc;
}, {}),
}
},
}
const nav = () => <Stack
direction="row"
spacing={1}
style={{
maxWidth: '1000px',
}}
>
<Button
variant="contained"
startIcon={<ArrowLeftOutlined />}
disabled={currentTab === 0}
fullWidth
onClick={() => {
setCurrentTab(currentTab - 1);
}}
>
Previous
</Button>
<Button
variant="contained"
fullWidth
endIcon={<ArrowRightOutlined />}
disabled={currentTab === 3}
onClick={() => {
setCurrentTab(currentTab + 1);
setMaxTab(Math.max(currentTab + 1, maxTab));
}}
>
Next
</Button>
</Stack>
return <div>
<Stack spacing={1}>
<Stack direction="row" spacing={1} alignItems="center">
<Back />
<div>Start New Servapp</div>
</Stack>
<IsLoggedIn />
{<PrettyTabbedView
currentTab={currentTab}
setCurrentTab={setCurrentTab}
tabs={[
{
title: 'Docker',
disabled: false,
children: <Stack spacing={2}><DockerContainerSetup newContainer containerInfo={containerInfo} OnChange={(values) => {
const newValues = {
...containerInfo,
Name: values.name,
Config: {
...containerInfo.Config,
Image: values.image,
Name: values.name,
Env: values.envVars,
Labels: values.labels,
},
HostConfig: {
...containerInfo.HostConfig,
RestartPolicy: {
Name: values.restartPolicy,
},
},
}
setContainerInfo(newValues);
}}
OnForceSecure={(value) => {
const newValues = {
...containerInfo,
Config: {
...containerInfo.Config,
Labels: {
...containerInfo.Config.Labels,
'cosmos-force-network-secured': ''+value,
},
},
}
setContainerInfo(newValues);
}}/>{nav()}</Stack>
},
{
title: 'Network',
disabled: maxTab < 1,
children: <Stack spacing={2}><NetworkContainerSetup newContainer containerInfo={containerInfo} OnChange={(values) => {
const newValues = {
...containerInfo,
Config: {
...containerInfo.Config,
ExposedPorts:
values.ports.map((port) => {
return `${port.port}/${port.protocol}`;
}),
},
HostConfig: {
...containerInfo.HostConfig,
PortBindings: values.ports.map((port) => {
return `${port.port}:${port.hostPort}/${port.protocol}`;
}),
},
NetworkSettings: {
...containerInfo.NetworkSettings,
Ports: values.ports && values.ports.reduce((acc, port) => {
acc[`${port.port}/${port.protocol}`] = [{
HostIp: '',
HostPort: `${port.hostPort}`,
}];
return acc;
}, {}),
},
}
setContainerInfo(newValues);
}} OnConnect={(net) => {
const newValues = {
...containerInfo,
NetworkSettings: {
...containerInfo.NetworkSettings,
Networks: {
...containerInfo.NetworkSettings.Networks,
[net]: {
Name: net,
},
},
},
}
setContainerInfo(newValues);
}} OnDisconnect={(net) => {
let newContainerInfo = {
...containerInfo,
}
delete newContainerInfo.NetworkSettings.Networks[net];
setContainerInfo(newContainerInfo);
}}/>{nav()}</Stack>
},
{
title: 'Storage',
disabled: maxTab < 2,
children: <Stack spacing={2}><VolumeContainerSetup ontainer containerInfo={containerInfo} OnChange={(values) => {
console.log(values)
const newValues = {
...containerInfo,
HostConfig: {
...containerInfo.HostConfig,
Mounts: values.volumes.map((volume) => {
return {
Type: volume.Type,
Source: volume.Source,
Target: volume.Target,
};
}),
},
}
setContainerInfo(newValues);
}} />{nav()}</Stack>
},
{
title: 'Review & Start',
disabled: maxTab < 3,
children: <Stack spacing={2}><NewDockerService service={service} />{nav()}</Stack>
}
]} />}
</Stack>
</div>;
}
export default NewDockerServiceForm;

View file

@ -1,6 +1,6 @@
import React from 'react';
import { Formik } from 'formik';
import { Button, Stack, Grid, MenuItem, TextField, IconButton, FormHelperText, useMediaQuery, useTheme, Alert } from '@mui/material';
import { Field, Formik } from 'formik';
import { Button, Stack, Grid, MenuItem, TextField, IconButton, FormHelperText, useMediaQuery, useTheme, Alert, FormControlLabel, Checkbox } from '@mui/material';
import MainCard from '../../../components/MainCard';
import { CosmosCheckbox, CosmosFormDivider, CosmosInputText, CosmosSelect }
from '../../config/users/formShortcuts';
@ -8,7 +8,25 @@ import { DeleteOutlined, PlusCircleOutlined } from '@ant-design/icons';
import * as API from '../../../api';
import { LoadingButton } from '@mui/lab';
const DockerContainerSetup = ({config, containerInfo, refresh}) => {
const containerInfoFrom = (values) => {
const labels = {};
values.labels.forEach((label) => {
labels[label.key] = label.value;
});
const envVars = values.envVars.map((envVar) => {
return `${envVar.key}=${envVar.value}`;
});
const realvalues = {
...values,
envVars: envVars,
labels: labels,
};
realvalues.interactive = realvalues.interactive ? 2 : 1;
return realvalues;
}
const DockerContainerSetup = ({config, containerInfo, OnChange, refresh, newContainer, OnForceSecure}) => {
const restartPolicies = [
['no', 'No Restart'],
['always', 'Always Restart'],
@ -23,6 +41,7 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => {
<div style={{ maxWidth: '1000px', width: '100%', margin: '', position: 'relative' }}>
<Formik
initialValues={{
name: containerInfo.Name.replace('/', ''),
image: containerInfo.Config.Image,
restartPolicy: containerInfo.HostConfig.RestartPolicy.Name,
envVars: containerInfo.Config.Env.map((envVar) => {
@ -50,23 +69,16 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => {
if (uniqueLabelKeys.length !== labelKeys.length) {
errors.submit = 'Labels must be unique';
}
OnChange && OnChange(containerInfoFrom(values));
return errors;
}}
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
if(newContainer) return false;
delete values.name;
setSubmitting(true);
const labels = {};
values.labels.forEach((label) => {
labels[label.key] = label.value;
});
const envVars = values.envVars.map((envVar) => {
return `${envVar.key}=${envVar.value}`;
});
const realvalues = {
...values,
envVars: envVars,
labels: labels,
};
realvalues.interactive = realvalues.interactive ? 2 : 1;
let realvalues = containerInfoFrom(values);
return API.docker.updateContainer(containerInfo.Name.replace('/', ''), realvalues)
.then((res) => {
@ -85,12 +97,18 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => {
<form noValidate onSubmit={formik.handleSubmit}>
<Stack spacing={2}>
<MainCard title={'Docker Container Setup'}>
{containerInfo.State.Status !== 'running' && (
{containerInfo.State && containerInfo.State.Status !== 'running' && (
<Alert severity="warning" style={{ marginBottom: '15px' }}>
This container is not running. Editing any settings will cause the container to start again.
</Alert>
)}
<Grid container spacing={4}>
{newContainer && <CosmosInputText
name="name"
label="Name"
placeholder="Name"
formik={formik}
/>}
<CosmosInputText
name="image"
label="Image"
@ -109,6 +127,21 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => {
label="Interactive Mode"
formik={formik}
/>
{OnForceSecure && <Grid item xs={12}>
<Checkbox
type="checkbox"
as={FormControlLabel}
control={<Checkbox size="large" />}
label={'Force secure container'}
checked={
containerInfo.Config.Labels.hasOwnProperty('cosmos-force-network-secured') &&
containerInfo.Config.Labels['cosmos-force-network-secured'] === 'true'
}
onChange={(e) => {
OnForceSecure(e.target.checked);
}}
/>
</Grid>}
<CosmosFormDivider title={'Environment Variables'} />
<Grid item xs={12}>
@ -228,7 +261,7 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => {
</Grid>
</Grid>
</MainCard>
<MainCard>
{!newContainer && <MainCard>
<Stack direction="column" spacing={2}>
{formik.errors.submit && (
<Grid item xs={12}>
@ -248,7 +281,7 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => {
Update
</LoadingButton>
</Stack>
</MainCard>
</MainCard>}
</Stack>
</form>
)}

View file

@ -12,7 +12,7 @@ import { NetworksColumns } from '../networks';
import NewNetworkButton from '../createNetwork';
import ResponsiveButton from '../../../components/responseiveButton';
const VolumeContainerSetup = ({ config, containerInfo, refresh }) => {
const VolumeContainerSetup = ({ config, containerInfo, refresh, newContainer, OnChange }) => {
const restartPolicies = [
['no', 'No Restart'],
['always', 'Always Restart'],
@ -32,11 +32,16 @@ const VolumeContainerSetup = ({ config, containerInfo, refresh }) => {
const refreshAll = () => {
setVolumes(null);
refresh().then(() => {
if(refresh)
refresh().then(() => {
API.docker.volumeList().then((res) => {
setVolumes(res.data.Volumes);
});
});
else
API.docker.volumeList().then((res) => {
setVolumes(res.data.Volumes);
});
});
};
return (<Stack spacing={2}>
@ -65,9 +70,11 @@ const VolumeContainerSetup = ({ config, containerInfo, refresh }) => {
if (unique.length !== volumes.length) {
errors.submit = 'Mounts must have unique targets';
}
OnChange && OnChange(values);
return errors;
}}
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
if(newContainer) return;
setSubmitting(true);
const realvalues = {
Volumes: values.volumes
@ -89,7 +96,7 @@ const VolumeContainerSetup = ({ config, containerInfo, refresh }) => {
{(formik) => (
<form noValidate onSubmit={formik.handleSubmit}>
<MainCard title={'Volume Mounts'}>
{containerInfo.State.Status !== 'running' && (
{containerInfo.State && containerInfo.State.Status !== 'running' && (
<Alert severity="warning" style={{ marginBottom: '15px' }}>
This container is not running. Editing any settings will cause the container to start again.
</Alert>
@ -230,7 +237,7 @@ const VolumeContainerSetup = ({ config, containerInfo, refresh }) => {
<FormHelperText error>{formik.errors.submit}</FormHelperText>
</Grid>
)}
<LoadingButton
{!newContainer && <LoadingButton
fullWidth
disableElevation
disabled={formik.errors.submit}
@ -241,7 +248,7 @@ const VolumeContainerSetup = ({ config, containerInfo, refresh }) => {
color="primary"
>
Update Volumes
</LoadingButton>
</LoadingButton>}
</Stack>
</Grid>
</Grid>

View file

@ -0,0 +1,113 @@
// material-ui
import * as React from 'react';
import { Alert, Button, Checkbox, FormControl, FormHelperText, Grid, InputLabel, MenuItem, Select, Stack, TextField } from '@mui/material';
import { PlusCircleFilled, PlusCircleOutlined, WarningOutlined } from '@ant-design/icons';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import CircularProgress from '@mui/material/CircularProgress';
import { useEffect, useState } from 'react';
import { LoadingButton } from '@mui/lab';
import { FormikProvider, useFormik } from 'formik';
import * as Yup from 'yup';
import * as API from '../../api';
import { CosmosCheckbox } from '../config/users/formShortcuts';
import ResponsiveButton from '../../components/responseiveButton';
import { CosmosContainerPicker } from '../config/users/containerPicker';
import { randomString } from '../../utils/indexs';
const LinkContainersButton = ({ fullWidth, refresh, originContainer, newContainer, OnConnect }) => {
const [isOpened, setIsOpened] = useState(false);
const formik = useFormik({
initialValues: {
container: '',
},
validationSchema: Yup.object({
container: Yup.string().required('Required'),
}),
validate: (values) => {
const errors = {};
if (values.container === originContainer) {
errors.container = 'Cannot link to itself';
}
return errors;
},
onSubmit: (values, { setErrors, setStatus, setSubmitting }) => {
setSubmitting(true);
let name = 'link-' + originContainer + '-' + values.container + '-' + randomString(3);
return API.docker.createNetwork({
name,
driver : 'bridge',
attachCosmos: false,
})
.then((res) => {
return Promise.all([
newContainer ? OnConnect(name) : API.docker.attachNetwork(originContainer, name),
API.docker.attachNetwork(values.container, name),
])
.then(() => {
setStatus({ success: true });
setSubmitting(false);
setIsOpened(false);
refresh && refresh();
})
.catch((err) => {
setStatus({ success: false });
setErrors({ submit: err.message });
setSubmitting(false);
});
})
},
});
return (
<>
<Dialog open={isOpened} onClose={() => setIsOpened(false)}>
<FormikProvider value={formik}>
<DialogTitle>Link with container</DialogTitle>
<DialogContent>
<DialogContentText>
<form onSubmit={formik.handleSubmit}>
<Stack spacing={2} width={'300px'} style={{ marginTop: '10px' }}>
<CosmosContainerPicker
formik={formik}
onTargetChange={(_, name) => {
formik.setFieldValue('container', name);
}}
nameOnly
/>
</Stack>
</form>
{formik.errors.submit && (
<Grid item xs={12}>
<FormHelperText error>{formik.errors.submit}</FormHelperText>
</Grid>
)}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsOpened(false)}>Cancel</Button>
<LoadingButton
disabled={formik.errors.submit}
onClick={formik.handleSubmit}
loading={formik.isSubmitting}>
Link Containers
</LoadingButton>
</DialogActions>
</FormikProvider>
</Dialog>
<ResponsiveButton
fullWidth={fullWidth}
onClick={() => setIsOpened(true)}
startIcon={<PlusCircleOutlined />}
>
Link Containers
</ResponsiveButton>
</>
);
};
export default LinkContainersButton;

View file

@ -17,6 +17,7 @@ import { Link } from 'react-router-dom';
import ExposeModal from './exposeModal';
import GetActions from './actionBar';
import ResponsiveButton from '../../components/responseiveButton';
import DockerComposeImport from './containers/docker-compose';
const Item = styled(Paper)(({ theme }) => ({
backgroundColor: theme.palette.mode === 'dark' ? '#1A2027' : '#fff',
@ -144,14 +145,13 @@ const ServeApps = () => {
<ResponsiveButton variant="contained" startIcon={<ReloadOutlined />} onClick={() => {
refreshServeApps();
}}>Refresh</ResponsiveButton>
<Tooltip title="This is not implemented yet.">
<span style={{ cursor: 'not-allowed' }}>
<ResponsiveButton
variant="contained"
startIcon={<AppstoreAddOutlined />}
disabled >Start ServApp</ResponsiveButton>
</span>
</Tooltip>
<Link to="/ui/servapps/new-service">
<ResponsiveButton
variant="contained"
startIcon={<AppstoreAddOutlined />}
>Start ServApp</ResponsiveButton>
</Link>
<DockerComposeImport />
</Stack>
<Grid2 container spacing={{xs: 1, sm: 1, md: 2 }}>

View file

@ -12,6 +12,8 @@ import RouteConfigPage from '../pages/config/routeConfigPage';
import logo from '../assets/images/icons/cosmos.png';
import HomePage from '../pages/home';
import ContainerIndex from '../pages/servapps/containers';
import NewDockerService from '../pages/servapps/containers/newService';
import NewDockerServiceForm from '../pages/servapps/containers/newServiceForm';
// render - dashboard
@ -63,6 +65,10 @@ const MainRoutes = {
path: '/ui/config-general',
element: <ConfigManagement />
},
{
path: '/ui/servapps/new-service',
element: <NewDockerServiceForm />
},
{
path: '/ui/config-url',
element: <ProxyManagement />

View file

@ -0,0 +1,8 @@
export const randomString = (length) => {
let text = "";
const possible =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
for (let i = 0; i < length; i++)
text += possible.charAt(Math.floor(Math.random() * possible.length));
return text;
}

13
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "cosmos-server",
"version": "0.4.0-unstable2",
"version": "0.5.0-unstable",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "cosmos-server",
"version": "0.4.0-unstable2",
"version": "0.5.0-unstable",
"dependencies": {
"@ant-design/colors": "^6.0.0",
"@ant-design/icons": "^4.7.0",
@ -25,6 +25,7 @@
"formik": "^2.2.9",
"framer-motion": "^7.3.6",
"history": "^5.3.0",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"prop-types": "^15.8.1",
"qrcode": "^1.5.3",
@ -4089,8 +4090,7 @@
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"node_modules/aria-query": {
"version": "5.1.3",
@ -7087,7 +7087,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"dependencies": {
"argparse": "^2.0.1"
},
@ -12203,8 +12202,7 @@
"argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"aria-query": {
"version": "5.1.3",
@ -14392,7 +14390,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"requires": {
"argparse": "^2.0.1"
}

View file

@ -1,6 +1,6 @@
{
"name": "cosmos-server",
"version": "0.5.0-unstable",
"version": "0.5.0-unstable2",
"description": "",
"main": "test-server.js",
"bugs": {
@ -25,6 +25,7 @@
"formik": "^2.2.9",
"framer-motion": "^7.3.6",
"history": "^5.3.0",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"prop-types": "^15.8.1",
"qrcode": "^1.5.3",

View file

@ -3,7 +3,6 @@ package configapi
import (
"encoding/json"
"net/http"
"sync"
"github.com/azukaar/cosmos-server/src/utils"
)
@ -14,15 +13,13 @@ type UpdateRouteRequest struct {
NewRoute *utils.ProxyRouteConfig `json:"newRoute,omitempty"`
}
var configLock sync.Mutex
func ConfigApiPatch(w http.ResponseWriter, req *http.Request) {
if utils.AdminOnly(w, req) != nil {
return
}
configLock.Lock()
defer configLock.Unlock()
utils.ConfigLock.Lock()
defer utils.ConfigLock.Unlock()
var updateReq UpdateRouteRequest
err := json.NewDecoder(req.Body).Decode(&updateReq)

564
src/docker/api_blueprint.go Normal file
View file

@ -0,0 +1,564 @@
package docker
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"bufio"
"errors"
"github.com/docker/go-connections/nat"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
conttype "github.com/docker/docker/api/types/container"
doctype "github.com/docker/docker/api/types"
strslice "github.com/docker/docker/api/types/strslice"
volumetype "github.com/docker/docker/api/types/volume"
"github.com/azukaar/cosmos-server/src/utils"
)
type ContainerCreateRequestContainer struct {
Name string `json:"container_name"`
Image string `json:"image"`
Environment []string `json:"environment"`
Labels map[string]string `json:"labels"`
Ports []string `json:"ports"`
Volumes []mount.Mount `json:"volumes"`
Networks map[string]struct {
Aliases []string `json:"aliases,omitempty"`
IPV4Address string `json:"ipv4_address,omitempty"`
IPV6Address string `json:"ipv6_address,omitempty"`
} `json:"networks"`
Routes []utils.ProxyRouteConfig `json:"routes"`
RestartPolicy string `json:"restart,omitempty"`
Devices []string `json:"devices"`
Expose []string `json:"expose"`
DependsOn []string `json:"depends_on"`
Tty bool `json:"tty,omitempty"`
StdinOpen bool `json:"stdin_open,omitempty"`
Command string `json:"command,omitempty"`
Entrypoint string `json:"entrypoint,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
User string `json:"user,omitempty"`
Hostname string `json:"hostname,omitempty"`
Domainname string `json:"domainname,omitempty"`
MacAddress string `json:"mac_address,omitempty"`
Privileged bool `json:"privileged,omitempty"`
NetworkMode string `json:"network_mode,omitempty"`
StopSignal string `json:"stop_signal,omitempty"`
StopGracePeriod int `json:"stop_grace_period,omitempty"`
HealthCheck struct {
Test []string `json:"test"`
Interval int `json:"interval"`
Timeout int `json:"timeout"`
Retries int `json:"retries"`
StartPeriod int `json:"start_period"`
} `json:"healthcheck,omitempty"`
DNS []string `json:"dns,omitempty"`
DNSSearch []string `json:"dns_search,omitempty"`
ExtraHosts []string `json:"extra_hosts,omitempty"`
Links []string `json:"links,omitempty"`
SecurityOpt []string `json:"security_opt,omitempty"`
StorageOpt map[string]string `json:"storage_opt,omitempty"`
Sysctls map[string]string `json:"sysctls,omitempty"`
Isolation string `json:"isolation,omitempty"`
CapAdd []string `json:"cap_add,omitempty"`
CapDrop []string `json:"cap_drop,omitempty"`
SysctlsMap map[string]string `json:"sysctls,omitempty"`
}
type ContainerCreateRequestVolume struct {
// name must be unique
Name string `json:"name"`
Driver string `json:"driver"`
Source string `json:"source"`
Target string `json:"target"`
}
type ContainerCreateRequestNetwork struct {
// name must be unique
Name string `json:"name"`
Driver string `json:"driver"`
Attachable bool `json:"attachable"`
Internal bool `json:"internal"`
EnableIPv6 bool `json:"enable_ipv6"`
IPAM struct {
Driver string `json:"driver"`
Config []struct {
Subnet string `json:"subnet"`
} `json:"config"`
} `json:"ipam"`
}
type DockerServiceCreateRequest struct {
Services map[string]ContainerCreateRequestContainer `json:"services"`
Volumes []ContainerCreateRequestVolume `json:"volumes"`
Networks map[string]ContainerCreateRequestNetwork `json:"networks"`
}
type DockerServiceCreateRollback struct {
// action: disconnect, remove, etc...
Action string `json:"action"`
// type: container, volume, network
Type string `json:"type"`
// name: container name, volume name, network name
Name string `json:"name"`
}
func Rollback(actions []DockerServiceCreateRollback , w http.ResponseWriter, flusher http.Flusher) {
for i := len(actions) - 1; i >= 0; i-- {
action := actions[i]
switch action.Type {
case "container":
DockerClient.ContainerKill(DockerContext, action.Name, "SIGKILL")
err := DockerClient.ContainerRemove(DockerContext, action.Name, doctype.ContainerRemoveOptions{})
if err != nil {
utils.Error("Rollback: Container", err)
} else {
utils.Log(fmt.Sprintf("Rolled back container %s", action.Name))
fmt.Fprintf(w, "Rolled back container %s\n", action.Name)
flusher.Flush()
}
case "volume":
err := DockerClient.VolumeRemove(DockerContext, action.Name, true)
if err != nil {
utils.Error("Rollback: Volume", err)
} else {
utils.Log(fmt.Sprintf("Rolled back volume %s", action.Name))
fmt.Fprintf(w, "Rolled back volume %s\n", action.Name)
flusher.Flush()
}
case "network":
err := DockerClient.NetworkRemove(DockerContext, action.Name)
if err != nil {
utils.Error("Rollback: Network", err)
} else {
utils.Log(fmt.Sprintf("Rolled back network %s", action.Name))
fmt.Fprintf(w, "Rolled back network %s\n", action.Name)
flusher.Flush()
}
}
}
// After all operations
utils.Error("CreateService", fmt.Errorf("Operation failed. Changes have been rolled back."))
fmt.Fprintf(w, "[OPERATION FAILED]. CHANGES HAVE BEEN ROLLEDBACK.\n")
flusher.Flush()
}
func CreateServiceRoute(w http.ResponseWriter, req *http.Request) {
if utils.AdminOnly(w, req) != nil {
return
}
errD := Connect()
if errD != nil {
utils.Error("CreateService", errD)
utils.HTTPError(w, "Internal server error: " + errD.Error(), http.StatusInternalServerError, "DS002")
return
}
if req.Method == "POST" {
// Enable streaming of response by setting appropriate headers
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Transfer-Encoding", "chunked")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
return
}
utils.ConfigLock.Lock()
defer utils.ConfigLock.Unlock()
utils.Log("Starting creation of new service...")
fmt.Fprintf(w, "Starting creation of new service...\n")
flusher.Flush()
config := utils.ReadConfigFromFile()
configRoutes := config.HTTPConfig.ProxyConfig.Routes
decoder := json.NewDecoder(req.Body)
var serviceRequest DockerServiceCreateRequest
err := decoder.Decode(&serviceRequest)
if err != nil {
utils.Error("CreateService", err)
utils.HTTPError(w, "Bad request: "+err.Error(), http.StatusBadRequest, "DS003")
return
}
var rollbackActions []DockerServiceCreateRollback
// Create networks
for networkToCreateName, networkToCreate := range serviceRequest.Networks {
utils.Log(fmt.Sprintf("Creating network %s...", networkToCreateName))
fmt.Fprintf(w, "Creating network %s...\n", networkToCreateName)
flusher.Flush()
// check if network already exists
_, err = DockerClient.NetworkInspect(DockerContext, networkToCreateName, doctype.NetworkInspectOptions{})
if err == nil {
utils.Error("CreateService: Network", err)
fmt.Fprintf(w, "[ERROR] Network %s already exists\n", networkToCreateName)
flusher.Flush()
Rollback(rollbackActions, w, flusher)
return
}
ipamConfig := make([]network.IPAMConfig, len(networkToCreate.IPAM.Config))
if networkToCreate.IPAM.Config != nil {
for i, config := range networkToCreate.IPAM.Config {
ipamConfig[i] = network.IPAMConfig{
Subnet: config.Subnet,
}
}
}
_, err = DockerClient.NetworkCreate(DockerContext, networkToCreateName, doctype.NetworkCreate{
Driver: networkToCreate.Driver,
Attachable: networkToCreate.Attachable,
Internal: networkToCreate.Internal,
EnableIPv6: networkToCreate.EnableIPv6,
IPAM: &network.IPAM{
Driver: networkToCreate.IPAM.Driver,
Config: ipamConfig,
},
})
if err != nil {
utils.Error("CreateService: Rolling back changes because of -- Network", err)
fmt.Fprintf(w, "[ERROR] Rolling back changes because of -- Network creation error: "+err.Error())
flusher.Flush()
Rollback(rollbackActions, w, flusher)
return
}
rollbackActions = append(rollbackActions, DockerServiceCreateRollback{
Action: "remove",
Type: "network",
Name: networkToCreateName,
})
// Write a response to the client
utils.Log(fmt.Sprintf("Network %s created", networkToCreateName))
fmt.Fprintf(w, "Network %s created\n", networkToCreateName)
flusher.Flush()
}
// Create volumes
for _, volume := range serviceRequest.Volumes {
utils.Log(fmt.Sprintf("Creating volume %s...", volume.Name))
fmt.Fprintf(w, "Creating volume %s...\n", volume.Name)
flusher.Flush()
_, err = DockerClient.VolumeCreate(DockerContext, volumetype.CreateOptions{
Driver: volume.Driver,
Name: volume.Name,
})
if err != nil {
utils.Error("CreateService: Rolling back changes because of -- Volume", err)
fmt.Fprintf(w, "[ERROR] Rolling back changes because of -- Volume creation error: "+err.Error())
flusher.Flush()
Rollback(rollbackActions, w, flusher)
return
}
rollbackActions = append(rollbackActions, DockerServiceCreateRollback{
Action: "remove",
Type: "volume",
Name: volume.Name,
})
// Write a response to the client
utils.Log(fmt.Sprintf("Volume %s created", volume.Name))
fmt.Fprintf(w, "Volume %s created\n", volume.Name)
flusher.Flush()
}
// pull images
for _, container := range serviceRequest.Services {
// Write a response to the client
utils.Log(fmt.Sprintf("Pulling image %s", container.Image))
fmt.Fprintf(w, "Pulling image %s\n", container.Image)
flusher.Flush()
out, err := DockerClient.ImagePull(DockerContext, container.Image, doctype.ImagePullOptions{})
if err != nil {
utils.Error("CreateService: Rolling back changes because of -- Image pull", err)
fmt.Fprintf(w, "[ERROR] Rolling back changes because of -- Image pull error: "+err.Error())
flusher.Flush()
Rollback(rollbackActions, w, flusher)
return
}
defer out.Close()
// wait for image pull to finish
scanner := bufio.NewScanner(out)
for scanner.Scan() {
fmt.Fprintf(w, "%s\n", scanner.Text())
flusher.Flush()
}
// Write a response to the client
utils.Log(fmt.Sprintf("Image %s pulled", container.Image))
fmt.Fprintf(w, "Image %s pulled\n", container.Image)
flusher.Flush()
}
// Create containers
for _, container := range serviceRequest.Services {
utils.Log(fmt.Sprintf("Creating container %s...", container.Name))
fmt.Fprintf(w, "Creating container %s...\n", container.Name)
flusher.Flush()
containerConfig := &conttype.Config{
Image: container.Image,
Env: container.Environment,
Labels: container.Labels,
ExposedPorts: nat.PortSet{},
WorkingDir: container.WorkingDir,
User: container.User,
Hostname: container.Hostname,
Domainname: container.Domainname,
MacAddress: container.MacAddress,
StopSignal: container.StopSignal,
StopTimeout: &container.StopGracePeriod,
Tty: container.Tty,
OpenStdin: container.StdinOpen,
}
if container.Command != "" {
containerConfig.Cmd = strings.Fields(container.Command)
}
if container.Entrypoint != "" {
containerConfig.Entrypoint = strslice.StrSlice(strings.Fields(container.Entrypoint))
}
// For Expose / Ports
for _, expose := range container.Expose {
exposePort := nat.Port(expose)
containerConfig.ExposedPorts[exposePort] = struct{}{}
}
PortBindings := nat.PortMap{}
for _, port := range container.Ports {
portContainer := strings.Split(port, ":")[0]
portHost := strings.Split(port, ":")[1]
containerConfig.ExposedPorts[nat.Port(portContainer)] = struct{}{}
PortBindings[nat.Port(portContainer)] = []nat.PortBinding{
{
HostIP: "",
HostPort: portHost,
},
}
}
hostConfig := &conttype.HostConfig{
PortBindings: PortBindings,
Mounts: container.Volumes,
RestartPolicy: conttype.RestartPolicy{
Name: container.RestartPolicy,
},
Privileged: container.Privileged,
NetworkMode: conttype.NetworkMode(container.NetworkMode),
DNS: container.DNS,
DNSSearch: container.DNSSearch,
ExtraHosts: container.ExtraHosts,
Links: container.Links,
SecurityOpt: container.SecurityOpt,
StorageOpt: container.StorageOpt,
Sysctls: container.Sysctls,
Isolation: conttype.Isolation(container.Isolation),
CapAdd: container.CapAdd,
CapDrop: container.CapDrop,
}
// For Healthcheck
if len(container.HealthCheck.Test) > 0 {
containerConfig.Healthcheck = &conttype.HealthConfig{
Test: container.HealthCheck.Test,
Interval: time.Duration(container.HealthCheck.Interval) * time.Second,
Timeout: time.Duration(container.HealthCheck.Timeout) * time.Second,
StartPeriod: time.Duration(container.HealthCheck.StartPeriod) * time.Second,
Retries: container.HealthCheck.Retries,
}
}
// For Devices
devices := []conttype.DeviceMapping{}
for _, device := range container.Devices {
deviceSplit := strings.Split(device, ":")
devices = append(devices, conttype.DeviceMapping{
PathOnHost: deviceSplit[0],
PathInContainer: deviceSplit[1],
CgroupPermissions: "rwm", // This can be "r", "w", "m", or any combination
})
}
hostConfig.Devices = devices
networkingConfig := &network.NetworkingConfig{
EndpointsConfig: make(map[string]*network.EndpointSettings),
}
for netName, netConfig := range container.Networks {
networkingConfig.EndpointsConfig[netName] = &network.EndpointSettings{
Aliases: netConfig.Aliases,
IPAddress: netConfig.IPV4Address,
GlobalIPv6Address: netConfig.IPV6Address,
}
}
_, err = DockerClient.ContainerCreate(DockerContext, containerConfig, hostConfig, networkingConfig, nil, container.Name)
if err != nil {
utils.Error("CreateService: Rolling back changes because of -- Container", err)
fmt.Fprintf(w, "[ERROR] Rolling back changes because of -- Container creation error: "+err.Error())
flusher.Flush()
Rollback(rollbackActions, w, flusher)
return
}
rollbackActions = append(rollbackActions, DockerServiceCreateRollback{
Action: "remove",
Type: "container",
Name: container.Name,
})
// add routes
for _, route := range container.Routes {
// check if route already exists
exists := false
for _, configRoute := range configRoutes {
if configRoute.Name == route.Name {
exists = true
break
}
}
if !exists {
configRoutes = append([]utils.ProxyRouteConfig{(utils.ProxyRouteConfig)(route)}, configRoutes...)
} else {
utils.Error("CreateService: Rolling back changes because of -- Route already exist", nil)
fmt.Fprintf(w, "[ERROR] Rolling back changes because of -- Route already exist")
flusher.Flush()
Rollback(rollbackActions, w, flusher)
return
}
}
// Write a response to the client
utils.Log(fmt.Sprintf("Container %s created", container.Name))
fmt.Fprintf(w, "Container %s created\n", container.Name)
flusher.Flush()
}
// re-order containers dpeneding on depends_on
startOrder, err := ReOrderServices(serviceRequest.Services)
if err != nil {
utils.Error("CreateService: Rolling back changes because of -- Container", err)
fmt.Fprintf(w, "[ERROR] Rolling back changes because of -- Container creation error: "+err.Error())
flusher.Flush()
Rollback(rollbackActions, w, flusher)
return
}
// Start all the newly created containers
for _, container := range startOrder {
err = DockerClient.ContainerStart(DockerContext, container.Name, doctype.ContainerStartOptions{})
if err != nil {
utils.Error("CreateService: Start Container", err)
fmt.Fprintf(w, "[ERROR] Rolling back changes because of -- Container start error: "+err.Error())
flusher.Flush()
Rollback(rollbackActions, w, flusher)
return
}
// Write a response to the client
utils.Log(fmt.Sprintf("Container %s started", container.Name))
fmt.Fprintf(w, "Container %s started\n", container.Name)
flusher.Flush()
}
// Save the route configs
config.HTTPConfig.ProxyConfig.Routes = configRoutes
utils.SaveConfigTofile(config)
utils.NeedsRestart = true
// After all operations
utils.Log("CreateService: Operation succeeded. SERVICE STARTED")
fmt.Fprintf(w, "[OPERATION SUCCEEDED]. SERVICE STARTED\n")
flusher.Flush()
} else {
utils.Error("CreateService: Method not allowed" + req.Method, nil)
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return
}
}
func ReOrderServices(serviceMap map[string]ContainerCreateRequestContainer) ([]ContainerCreateRequestContainer, error) {
startOrder := []ContainerCreateRequestContainer{}
for len(serviceMap) > 0 {
// Keep track of whether we've added any services in this iteration
changed := false
for name, service := range serviceMap {
// Check if all dependencies are already in startOrder
allDependenciesStarted := true
for _, dependency := range service.DependsOn {
dependencyStarted := false
for _, startedService := range startOrder {
if startedService.Name == dependency {
dependencyStarted = true
break
}
}
if !dependencyStarted {
allDependenciesStarted = false
break
}
}
// If all dependencies are started, we can add this service to startOrder
if allDependenciesStarted {
startOrder = append(startOrder, service)
delete(serviceMap, name)
changed = true
}
}
// If we haven't added any services in this iteration, then there must be a circular dependency
if !changed {
break
}
}
// If there are any services left in serviceMap, they couldn't be started due to unsatisfied dependencies or circular dependencies
if len(serviceMap) > 0 {
errorMessage := "Could not start all services due to unsatisfied dependencies or circular dependencies:\n"
for name, _ := range serviceMap {
errorMessage += "Could not start service: " + name + "\n"
errorMessage += "Unsatisfied dependencies:\n"
for _, dependency := range serviceMap[name].DependsOn {
_, ok := serviceMap[dependency]
if ok {
errorMessage += dependency + "\n"
}
}
}
return nil, errors.New(errorMessage)
}
return startOrder, nil
}

43
src/docker/api_images.go Normal file
View file

@ -0,0 +1,43 @@
package docker
import (
"net/http"
"encoding/json"
"github.com/azukaar/cosmos-server/src/utils"
"github.com/gorilla/mux"
)
func InspectImageRoute(w http.ResponseWriter, req *http.Request) {
if utils.AdminOnly(w, req) != nil {
return
}
errD := Connect()
if errD != nil {
utils.Error("InspectImage", errD)
utils.HTTPError(w, "Internal server error: " + errD.Error(), http.StatusInternalServerError, "DS002")
return
}
vars := mux.Vars(req)
imageName := utils.SanitizeSafe(vars["imageName"])
utils.Log("InspectImage " + imageName)
if req.Method == "GET" {
image, _, err := DockerClient.ImageInspectWithRaw(DockerContext, imageName)
if err != nil {
utils.Error("InspectImage", err)
utils.HTTPError(w, "Internal server error: " + err.Error(), http.StatusInternalServerError, "DS002")
return
}
json.NewEncoder(w).Encode(image)
} else {
utils.Error("InspectImage: Method not allowed" + req.Method, nil)
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return
}
}

View file

@ -218,6 +218,8 @@ func StartServer() {
srapi.HandleFunc("/api/users/{nickname}", user.UsersIdRoute)
srapi.HandleFunc("/api/users", user.UsersRoute)
srapi.HandleFunc("/api/images/{imageName}", docker.InspectImageRoute)
srapi.HandleFunc("/api/volume/{volumeName}", docker.DeleteVolumeRoute)
srapi.HandleFunc("/api/volumes", docker.VolumesRoute)
@ -234,6 +236,7 @@ func StartServer() {
srapi.HandleFunc("/api/servapps/{containerId}/networks", docker.NetworkContainerRoutes)
srapi.HandleFunc("/api/servapps", docker.ContainersRoute)
srapi.HandleFunc("/api/docker-service", docker.CreateServiceRoute)
if(!config.HTTPConfig.AcceptAllInsecureHostname) {
srapi.Use(utils.EnsureHostname)

View file

@ -25,6 +25,7 @@ type userBan struct {
ClientID string
banType int
time time.Time
reason string
}
type smartShieldState struct {
@ -94,6 +95,23 @@ func (shield *smartShieldState) GetUserUsedBudgets(ClientID string) userUsedBudg
return userConsumed
}
func (shield *smartShieldState) GetLastBan(policy utils.SmartShieldPolicy, userConsumed userUsedBudget) *userBan {
shield.Lock()
defer shield.Unlock()
ClientID := userConsumed.ClientID
// Check for bans
for i := len(shield.bans) - 1; i >= 0; i-- {
ban := shield.bans[i]
if ban.banType == STRIKE && ban.ClientID == ClientID {
return ban
}
}
return nil
}
func (shield *smartShieldState) isAllowedToReqest(policy utils.SmartShieldPolicy, userConsumed userUsedBudget) bool {
shield.Lock()
defer shield.Unlock()
@ -106,16 +124,15 @@ func (shield *smartShieldState) isAllowedToReqest(policy utils.SmartShieldPolicy
// Check for bans
for i := len(shield.bans) - 1; i >= 0; i-- {
ban := shield.bans[i]
if ban.banType == PERM {
if ban.banType == PERM && ban.ClientID == ClientID {
return false
} else if ban.banType == TEMP {
} else if ban.banType == TEMP && ban.ClientID == ClientID {
if(ban.time.Add(4 * 3600 * time.Second).Before(time.Now())) {
return false
} else if (ban.time.Add(72 * 3600 * time.Second).Before(time.Now())) {
nbTempBans++
}
} else if ban.banType == STRIKE {
return false
} else if ban.banType == STRIKE && ban.ClientID == ClientID {
if(ban.time.Add(3600 * time.Second).Before(time.Now())) {
return false
} else if (ban.time.Add(24 * 3600 * time.Second).Before(time.Now())) {
@ -154,6 +171,7 @@ func (shield *smartShieldState) isAllowedToReqest(policy utils.SmartShieldPolicy
ClientID: ClientID,
banType: STRIKE,
time: time.Now(),
reason: fmt.Sprintf("%+v out of %+v", userConsumed, policy),
})
utils.Warn("User " + ClientID + " has received a strike: "+ fmt.Sprintf("%+v", userConsumed))
return false
@ -285,8 +303,8 @@ func SmartShieldMiddleware(policy utils.SmartShieldPolicy) func(http.Handler) ht
userConsumed := shield.GetUserUsedBudgets(clientID)
if !isPrivileged(r, policy) && !shield.isAllowedToReqest(policy, userConsumed) {
utils.Log("SmartShield: User is blocked due to abuse: " + fmt.Sprintf("%+v", userConsumed))
lastBan := shield.GetLastBan(policy, userConsumed)
utils.Log("SmartShield: User is blocked due to abuse: " + fmt.Sprintf("%+v", lastBan))
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
} else {

View file

@ -11,11 +11,14 @@ import (
"strings"
"io/ioutil"
"fmt"
"sync"
"path/filepath"
"github.com/shirou/gopsutil/v3/mem"
)
var ConfigLock sync.Mutex
var BaseMainConfig Config
var MainConfig Config
var IsHTTPS = false