[release] v0.4.0-unstable5

This commit is contained in:
Yann Stepienik 2023-05-07 17:47:20 +01:00
parent 7e37cfb996
commit 6a8e97b242
22 changed files with 1669 additions and 172 deletions

View file

@ -2,6 +2,7 @@
- Protect server against direct IP access - Protect server against direct IP access
- Improvements to installer to make it more robust - Improvements to installer to make it more robust
- Fix bug where you can't complete the setup if you don't have a database - Fix bug where you can't complete the setup if you don't have a database
- When re-creating a container to edit it, restore the previous container if the edit is not succesful
- Stop / Start / Restart / Remove / Kill containers - Stop / Start / Restart / Remove / Kill containers
## Version 0.3.0 ## Version 0.3.0

View file

@ -91,15 +91,72 @@ const newDB = () => {
})) }))
} }
const manageContainer = (id, action) => { const manageContainer = (containerId, action) => {
return wrap(fetch('/cosmos/api/servapps/' + id + '/manage/' + action, { return wrap(fetch('/cosmos/api/servapps/' + containerId + '/manage/' + action, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
})) }))
} }
function updateContainer(containerId, values) {
return wrap(fetch('/cosmos/api/servapps/' + containerId + '/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(values),
}))
}
function listContainerNetworks(containerId) {
return wrap(fetch('/cosmos/api/servapps/' + containerId + '/networks', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}))
}
function createNetwork(values) {
return wrap(fetch('/cosmos/api/networks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(values),
}))
}
function attachNetwork(containerId, networkId) {
return wrap(fetch('/cosmos/api/servapps/' + containerId + '/network/' + networkId, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}))
}
function detachNetwork(containerId, networkId) {
return wrap(fetch('/cosmos/api/servapps/' + containerId + '/network/' + networkId, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
}))
}
function createVolume(values) {
return wrap(fetch('/cosmos/api/volumes', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(values),
}))
}
export { export {
list, list,
get, get,
@ -110,5 +167,11 @@ export {
volumeDelete, volumeDelete,
networkList, networkList,
networkDelete, networkDelete,
getContainerLogs getContainerLogs,
updateContainer,
listContainerNetworks,
createNetwork,
attachNetwork,
detachNetwork,
createVolume,
}; };

View file

@ -15,6 +15,7 @@ export default function wrap(apicall) {
snackit(rep.message); snackit(rep.message);
const e = new Error(rep.message); const e = new Error(rep.message);
e.status = response.status; e.status = response.status;
e.code = rep.code;
throw e; throw e;
}); });
} }

View file

@ -11,7 +11,7 @@ import { SearchOutlined } from '@ant-design/icons';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
const PrettyTableView = ({ getKey, data, columns, onRowClick, linkTo }) => { const PrettyTableView = ({ getKey, data, columns, onRowClick, linkTo, buttons, fullWidth }) => {
const [search, setSearch] = React.useState(''); const [search, setSearch] = React.useState('');
const theme = useTheme(); const theme = useTheme();
const isDark = theme.palette.mode === 'dark'; const isDark = theme.palette.mode === 'dark';
@ -25,23 +25,25 @@ const PrettyTableView = ({ getKey, data, columns, onRowClick, linkTo }) => {
} }
return ( return (
<Stack direction="column" spacing={2}> <Stack direction="column" spacing={2} style={{width: fullWidth ? '100%': ''}}>
<Input placeholder="Search" <Stack direction="row" spacing={2}>
value={search} <Input placeholder="Search"
style={{ value={search}
width: '250px', style={{
}} width: '250px',
startAdornment={ }}
<InputAdornment position="start"> startAdornment={
<SearchOutlined /> <InputAdornment position="start">
</InputAdornment> <SearchOutlined />
} </InputAdornment>
onChange={(e) => { }
setSearch(e.target.value); onChange={(e) => {
}} setSearch(e.target.value);
/> }}
/>
<TableContainer style={{background: isDark ? '#252b32' : '', borderTop: '3px solid ' + theme.palette.primary.main}} component={Paper}> {buttons}
</Stack>
<TableContainer style={{width: fullWidth ? '100%': '', background: isDark ? '#252b32' : '', borderTop: '3px solid ' + theme.palette.primary.main}} component={Paper}>
<Table aria-label="simple table"> <Table aria-label="simple table">
<TableHead> <TableHead>
<TableRow> <TableRow>

View file

@ -99,30 +99,22 @@ const AuthLogin = () => {
password: Yup.string().max(255).required('Password is required') password: Yup.string().max(255).required('Password is required')
})} })}
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => { onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
try { setSubmitting(true);
API.auth.login(values).then((data) => { return API.auth.login(values).then((data) => {
if(data.status == 'error') { setStatus({ success: true });
setStatus({ success: false });
if(data.code == 'UL001') {
setErrors({ submit: 'Wrong nickname or password. Try again or try resetting your password' });
} else if (data.code == 'UL002') {
setErrors({ submit: 'You have not yet registered your account. You should have an invite link in your emails. If you need a new one, contact your administrator.' });
} else if(data.status == 'error') {
setErrors({ submit: 'Unexpected error. Try again later.' });
}
setSubmitting(false);
return;
} else {
setStatus({ success: true });
setSubmitting(false);
window.location.href = redirectTo;
}
})
} catch (err) {
setStatus({ success: false });
setErrors({ submit: err.message });
setSubmitting(false); setSubmitting(false);
} window.location.href = redirectTo;
}).catch((err) => {
setStatus({ success: false });
if(err.code == 'UL001') {
setErrors({ submit: 'Wrong nickname or password. Try again or try resetting your password' });
} else if (err.code == 'UL002') {
setErrors({ submit: 'You have not yet registered your account. You should have an invite link in your emails. If you need a new one, contact your administrator.' });
} else {
setErrors({ submit: 'Unexpected error. Try again later.' });
}
setSubmitting(false);
});
}} }}
> >
{({ errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values }) => ( {({ errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values }) => (

View file

@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import MainCard from '../../../components/MainCard'; import MainCard from '../../../components/MainCard';
import RestartModal from '../../config/users/restart'; import RestartModal from '../../config/users/restart';
import { Chip, Divider, Stack, useMediaQuery } from '@mui/material'; import { Alert, Chip, Divider, Stack, useMediaQuery } from '@mui/material';
import HostChip from '../../../components/hostChip'; import HostChip from '../../../components/hostChip';
import { RouteMode, RouteSecurity } from '../../../components/routeComponents'; import { RouteMode, RouteSecurity } from '../../../components/routeComponents';
import { getFaviconURL } from '../../../utils/routes'; import { getFaviconURL } from '../../../utils/routes';
@ -13,6 +13,9 @@ import Back from '../../../components/back';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import ContainerOverview from './overview'; import ContainerOverview from './overview';
import Logs from './logs'; import Logs from './logs';
import DockerContainerSetup from './setup';
import NetworkContainerSetup from './network';
import VolumeContainerSetup from './volumes';
const ContainerIndex = () => { const ContainerIndex = () => {
const { containerName } = useParams(); const { containerName } = useParams();
@ -53,30 +56,27 @@ const ContainerIndex = () => {
}, },
{ {
title: 'Terminal', title: 'Terminal',
children: <Logs containerInfo={container} config={config}/> children: <div>
<Alert severity="info">This feature is not yet implemented. It is planned for next version: 0.5.0</Alert>
</div>
}, },
{ {
title: 'Links', title: 'Links',
children: <div>Links</div> children: <div>
<Alert severity="info">This feature is not yet implemented. It is planned for next version: 0.5.0</Alert>
</div>
}, },
// {
// title: 'Advanced'
// },
{ {
title: 'Setup', title: 'Docker',
children: <div>Image, Restart Policy, Environment Variables, Labels, etc...</div> children: <DockerContainerSetup refresh={refreshContainer} containerInfo={container} config={config}/>
}, },
{ {
title: 'Network', title: 'Network',
children: <div>Urls, Networks, Ports, etc...</div> children: <NetworkContainerSetup refresh={refreshContainer} containerInfo={container} config={config}/>
}, },
{ {
title: 'Volumes', title: 'Volumes',
children: <div>Volumes</div> children: <VolumeContainerSetup refresh={refreshContainer} containerInfo={container} config={config}/>
},
{
title: 'Resources',
children: <div>Runtime Resources, Capabilities...</div>
}, },
]} /> ]} />
</Stack> </Stack>

View file

@ -0,0 +1,264 @@
import React from 'react';
import { Formik } from 'formik';
import { Button, Stack, Grid, MenuItem, TextField, IconButton, FormHelperText, CircularProgress, useTheme } from '@mui/material';
import MainCard from '../../../components/MainCard';
import { CosmosCheckbox, CosmosFormDivider, CosmosInputText, CosmosSelect }
from '../../config/users/formShortcuts';
import { ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, DeleteOutlined, PlusCircleOutlined } from '@ant-design/icons';
import * as API from '../../../api';
import { LoadingButton } from '@mui/lab';
import PrettyTableView from '../../../components/tableView/prettyTableView';
import { NetworksColumns } from '../networks';
import NewNetworkButton from '../createNetwork';
const NetworkContainerSetup = ({ config, containerInfo, refresh }) => {
const restartPolicies = [
['no', 'No Restart'],
['always', 'Always Restart'],
['on-failure', 'Restart On Failure'],
['unless-stopped', 'Restart Unless Stopped'],
];
const [networks, setNetworks] = React.useState([]);
const theme = useTheme();
const isDark = theme.palette.mode === 'dark';
React.useEffect(() => {
API.docker.networkList().then((res) => {
setNetworks(res.data);
});
}, []);
const refreshAll = () => {
setNetworks(null);
refresh().then(() => {
API.docker.networkList().then((res) => {
setNetworks(res.data);
});
});
};
const connect = (network) => {
setNetworks(null);
return API.docker.attachNetwork(containerInfo.Id, network).then(() => {
refreshAll();
});
}
const disconnect = (network) => {
return API.docker.detachNetwork(containerInfo.Id, network).then(() => {
refreshAll();
});
}
return (<Stack spacing={2}>
<div style={{ maxWidth: '1000px', width: '100%', margin: '', position: 'relative' }}>
<Formik
initialValues={{
ports: Object.keys(containerInfo.NetworkSettings.Ports).map((port) => {
return {
port: port.split('/')[0],
protocol: port.split('/')[1],
hostPort: containerInfo.NetworkSettings.Ports[port] ?
containerInfo.NetworkSettings.Ports[port][0].HostPort : '',
};
})
}}
validate={(values) => {
const errors = {};
// check unique
const ports = values.ports.map((port) => {
return `${port.port}/${port.protocol}`;
});
const unique = [...new Set(ports)];
if (unique.length !== ports.length) {
errors.submit = 'Ports must be unique';
}
return errors;
}}
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
setSubmitting(true);
const realvalues = {
portBindings: {},
};
values.ports.forEach((port) => {
if (port.hostPort) {
realvalues.portBindings[`${port.port}/${port.protocol}`] = [
{
HostPort: port.hostPort,
}
];
}
});
return API.docker.updateContainer(containerInfo.Name.replace('/', ''), realvalues)
.then((res) => {
setStatus({ success: true });
setSubmitting(false);
}
).catch((err) => {
setStatus({ success: false });
setErrors({ submit: err.message });
setSubmitting(false);
});
}}
>
{(formik) => (
<form noValidate onSubmit={formik.handleSubmit}>
<Stack spacing={2}>
<MainCard title={'Exposed Ports'}>
<Grid container spacing={4}>
<Grid item xs={12}>
{formik.values.ports.map((port, idx) => (
<Grid container spacing={2} key={idx}>
<Grid item xs={4} style={{ padding: '20px 10px' }}>
<TextField
label="Container Port"
fullWidth
value={port.port}
onChange={(e) => {
const newports = [...formik.values.ports];
newports[idx].port = e.target.value;
formik.setFieldValue('ports', newports);
}}
/>
</Grid>
<Grid item xs={4} style={{ padding: '20px 10px' }}>
<TextField
fullWidth
label="Host port"
value={'' + port.hostPort}
onChange={(e) => {
const newports = [...formik.values.ports];
newports[idx].hostPort = e.target.value;
formik.setFieldValue('ports', newports);
}}
/>
</Grid>
<Grid item xs={3} style={{ padding: '20px 10px' }}>
<TextField
className="px-2 my-2"
variant="outlined"
name='protocol'
id='protocol'
select
value={port.protocol}
onChange={(e) => {
const newports = [...formik.values.ports];
newports[idx].protocol = e.target.value;
formik.setFieldValue('ports', newports);
}}
>
<MenuItem value="tcp">TCP</MenuItem>
<MenuItem value="udp">UDP</MenuItem>
</TextField>
</Grid>
<Grid item xs={1} style={{ padding: '20px 10px' }}>
<IconButton
fullWidth
variant="outlined"
color="error"
onClick={() => {
const newports = [...formik.values.ports];
newports.splice(idx, 1);
formik.setFieldValue('ports', newports);
}}
>
<DeleteOutlined />
</IconButton>
</Grid>
</Grid>
))}
<IconButton
fullWidth
variant="outlined"
color="primary"
size='large'
onClick={() => {
const newports = [...formik.values.ports];
newports.push({
port: '',
protocol: 'tcp',
hostPort: '',
});
formik.setFieldValue('ports', newports);
}}
>
<PlusCircleOutlined />
</IconButton>
</Grid>
<Grid item xs={12}>
<Stack direction="column" spacing={2}>
{formik.errors.submit && (
<Grid item xs={12}>
<FormHelperText error>{formik.errors.submit}</FormHelperText>
</Grid>
)}
<LoadingButton
fullWidth
disableElevation
disabled={formik.errors.submit}
loading={formik.isSubmitting}
size="large"
type="submit"
variant="contained"
color="primary"
>
Update Ports
</LoadingButton>
</Stack>
</Grid>
</Grid>
</MainCard>
<MainCard title={'Connected Networks'}>
{networks && <PrettyTableView
data={networks}
buttons={[
<NewNetworkButton refresh={refreshAll} />,
]}
onRowClick={() => { }}
getKey={(r) => r.Id}
columns={[
{
title: '',
field: (r) => {
const isConnected = containerInfo.NetworkSettings.Networks[r.Name];
// icon
return isConnected ?
<ApiOutlined style={{ color: 'green' }} /> :
<ApiOutlined style={{ color: 'red' }} />
}
},
...NetworksColumns(theme, isDark),
{
title: '',
field: (r) => {
const isConnected = containerInfo.NetworkSettings.Networks[r.Name];
return (<Button
variant="outlined"
color="primary"
onClick={() => {
isConnected ? disconnect(r.Name) : connect(r.Name);
}}>
{isConnected ? 'Disconnect' : 'Connect'}
</Button>)
}
}
]}
/>}
{!networks && (<div style={{ height: '550px' }}>
<center>
<br />
<CircularProgress />
</center>
</div>
)}
</MainCard>
</Stack>
</form>
)}
</Formik>
</div>
</Stack>);
};
export default NetworkContainerSetup;

View file

@ -0,0 +1,241 @@
import React from 'react';
import { Formik } from 'formik';
import { Button, Stack, Grid, MenuItem, TextField, IconButton, FormHelperText } from '@mui/material';
import MainCard from '../../../components/MainCard';
import { CosmosCheckbox, CosmosFormDivider, CosmosInputText, CosmosSelect }
from '../../config/users/formShortcuts';
import { DeleteOutlined, PlusCircleOutlined } from '@ant-design/icons';
import * as API from '../../../api';
import { LoadingButton } from '@mui/lab';
const DockerContainerSetup = ({config, containerInfo}) => {
const restartPolicies = [
['no', 'No Restart'],
['always', 'Always Restart'],
['on-failure', 'Restart On Failure'],
['unless-stopped', 'Restart Unless Stopped'],
];
return (
<div style={{ maxWidth: '1000px', width: '100%', margin: '', position: 'relative' }}>
<Formik
initialValues={{
image: containerInfo.Config.Image,
restartPolicy: containerInfo.HostConfig.RestartPolicy.Name,
envVars: containerInfo.Config.Env.map((envVar) => {
const [key, value] = envVar.split('=');
return { key, value };
}),
labels: Object.keys(containerInfo.Config.Labels).map((key) => {
return { key, value: containerInfo.Config.Labels[key] };
}),
}}
validate={(values) => {
const errors = {};
if (!values.image) {
errors.image = 'Required';
}
// env keys and labels key mustbe unique
const envKeys = values.envVars.map((envVar) => envVar.key);
const labelKeys = values.labels.map((label) => label.key);
const uniqueEnvKeysKeys = [...new Set(envKeys)];
const uniqueLabelKeys = [...new Set(labelKeys)];
if (uniqueEnvKeysKeys.length !== envKeys.length) {
errors.submit = 'Environment Variables must be unique';
}
if (uniqueLabelKeys.length !== labelKeys.length) {
errors.submit = 'Labels must be unique';
}
return errors;
}}
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
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,
};
return API.docker.updateContainer(containerInfo.Name.replace('/', ''), realvalues)
.then((res) => {
setStatus({ success: true });
setSubmitting(false);
}
).catch((err) => {
setStatus({ success: false });
setErrors({ submit: err.message });
setSubmitting(false);
});
}}
>
{(formik) => (
<form noValidate onSubmit={formik.handleSubmit}>
<Stack spacing={2}>
<MainCard title={'Docker Container Setup'}>
<Grid container spacing={4}>
<CosmosInputText
name="image"
label="Image"
placeholder="Image"
formik={formik}
/>
<CosmosSelect
name="restartPolicy"
label="Restart Policy"
placeholder="Restart Policy"
options={restartPolicies}
formik={formik}
/>
<CosmosFormDivider title={'Environment Variables'} />
<Grid item xs={12}>
{formik.values.envVars.map((envVar, idx) => (
<Grid container spacing={2} key={idx}>
<Grid item xs={5} style={{padding: '20px 10px'}}>
<TextField
label="Key"
fullWidth
value={envVar.key}
onChange={(e) => {
const newEnvVars = [...formik.values.envVars];
newEnvVars[idx].key = e.target.value;
formik.setFieldValue('envVars', newEnvVars);
}}
/>
</Grid>
<Grid item xs={6} style={{padding: '20px 10px'}}>
<TextField
fullWidth
label="Value"
value={envVar.value}
onChange={(e) => {
const newEnvVars = [...formik.values.envVars];
newEnvVars[idx].value = e.target.value;
formik.setFieldValue('envVars', newEnvVars);
}}
/>
</Grid>
<Grid item xs={1} style={{padding: '20px 10px'}}>
<IconButton
fullWidth
variant="outlined"
color="error"
onClick={() => {
const newEnvVars = [...formik.values.envVars];
newEnvVars.splice(idx, 1);
formik.setFieldValue('envVars', newEnvVars);
}}
>
<DeleteOutlined />
</IconButton>
</Grid>
</Grid>
))}
<IconButton
fullWidth
variant="outlined"
color="primary"
size='large'
onClick={() => {
const newEnvVars = [...formik.values.envVars];
newEnvVars.push({ key: '', value: '' });
formik.setFieldValue('envVars', newEnvVars);
}}
>
<PlusCircleOutlined />
</IconButton>
</Grid>
<CosmosFormDivider title={'Labels'} />
<Grid item xs={12}>
{formik.values.labels.map((label, idx) => (
<Grid container spacing={2} key={idx}>
<Grid item xs={5} style={{padding: '20px 10px'}}>
<TextField
fullWidth
label="Key"
value={label.key}
onChange={(e) => {
const newLabels = [...formik.values.labels];
newLabels[idx].key = e.target.value;
formik.setFieldValue('labels', newLabels);
}}
/>
</Grid>
<Grid item xs={6} style={{padding: '20px 10px'}}>
<TextField
label="Value"
fullWidth
value={label.value}
onChange={(e) => {
const newLabels = [...formik.values.labels];
newLabels[idx].value = e.target.value;
formik.setFieldValue('labels', newLabels);
}}
/>
</Grid>
<Grid item xs={1} style={{padding: '20px 10px'}}>
<IconButton
fullWidth
variant="outlined"
color="error"
onClick={() => {
const newLabels = [...formik.values.labels];
newLabels.splice(idx, 1);
formik.setFieldValue('labels', newLabels);
}}
>
<DeleteOutlined />
</IconButton>
</Grid>
</Grid>
))}
<IconButton
fullWidth
variant="outlined"
color="primary"
size='large'
onClick={() => {
const newLabels = [...formik.values.labels];
newLabels.push({ key: '', value: '' });
formik.setFieldValue('labels', newLabels);
}}
>
<PlusCircleOutlined />
</IconButton>
</Grid>
</Grid>
</MainCard>
<MainCard>
<Stack direction="column" spacing={2}>
{formik.errors.submit && (
<Grid item xs={12}>
<FormHelperText error>{formik.errors.submit}</FormHelperText>
</Grid>
)}
<LoadingButton
fullWidth
disableElevation
disabled={formik.errors.submit}
loading={formik.isSubmitting}
size="large"
type="submit"
variant="contained"
color="primary"
>
Update
</LoadingButton>
</Stack>
</MainCard>
</Stack>
</form>
)}
</Formik>
</div>);
};
export default DockerContainerSetup;

View file

@ -0,0 +1,249 @@
import React from 'react';
import { Formik } from 'formik';
import { Button, Stack, Grid, MenuItem, TextField, IconButton, FormHelperText, CircularProgress, useTheme, Checkbox } from '@mui/material';
import MainCard from '../../../components/MainCard';
import { CosmosCheckbox, CosmosFormDivider, CosmosInputText, CosmosSelect }
from '../../config/users/formShortcuts';
import { ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, DeleteOutlined, PlusCircleOutlined } from '@ant-design/icons';
import * as API from '../../../api';
import { LoadingButton } from '@mui/lab';
import PrettyTableView from '../../../components/tableView/prettyTableView';
import { NetworksColumns } from '../networks';
import NewNetworkButton from '../createNetwork';
const VolumeContainerSetup = ({ config, containerInfo, refresh }) => {
const restartPolicies = [
['no', 'No Restart'],
['always', 'Always Restart'],
['on-failure', 'Restart On Failure'],
['unless-stopped', 'Restart Unless Stopped'],
];
const [volumes, setVolumes] = React.useState([]);
const theme = useTheme();
const isDark = theme.palette.mode === 'dark';
React.useEffect(() => {
API.docker.networkList().then((res) => {
setVolumes(res.data);
});
}, []);
const refreshAll = () => {
setVolumes(null);
refresh().then(() => {
API.docker.networkList().then((res) => {
setVolumes(res.data);
});
});
};
return (<Stack spacing={2}>
<div style={{ maxWidth: '1000px', width: '100%', margin: '', position: 'relative' }}>
<Formik
initialValues={{
volumes: ([
...(containerInfo.HostConfig.Mounts || []),
...(containerInfo.HostConfig.Binds || []).map((bind) => {
const [source, destination, mode] = bind.split(':');
return {
Type: 'bind',
Source: source,
Target: destination,
}
})
])
}}
validate={(values) => {
const errors = {};
// check unique
const volumes = values.volumes.map((volume) => {
return `${volume.Destination}`;
});
const unique = [...new Set(volumes)];
if (unique.length !== volumes.length) {
errors.submit = 'Mounts must have unique destinations';
}
return errors;
}}
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
setSubmitting(true);
const realvalues = {
Volumes: values.volumes
};
return API.docker.updateContainer(containerInfo.Name.replace('/', ''), realvalues)
.then((res) => {
setStatus({ success: true });
setSubmitting(false);
}
).catch((err) => {
setStatus({ success: false });
setErrors({ submit: err.message });
setSubmitting(false);
});
}}
>
{(formik) => (
<form noValidate onSubmit={formik.handleSubmit}>
<MainCard title={'Volume Mounts'}>
<Grid container spacing={4}>
<Grid item xs={12}>
{volumes && <PrettyTableView
data={formik.values.volumes}
onRowClick={() => { }}
getKey={(r) => r.Id}
fullWidth
buttons={[
<Button variant="outlined" color="primary" onClick={() => {
formik.setFieldValue('volumes', [...formik.values.volumes, {
Type: 'volume',
Name: '',
Driver: 'local',
Source: '',
Destination: '',
RW: true
}]);
}}>
New Mount Point
</Button>
]}
columns={[
{
title: 'Type',
field: (r, k) => (
<div style={{fontWeight: 'bold', wordSpace: 'nowrap', overflow:'hidden', textOverflow:'ellipsis', maxWidth: '100px'}}>
<TextField
className="px-2 my-2"
variant="outlined"
name='Type'
id='Type'
select
value={r.Type}
onChange={(e) => {
const newVolumes = [...formik.values.volumes];
newVolumes[k].Type = e.target.value;
formik.setFieldValue('volumes', newVolumes);
}}
>
<MenuItem value="bind">Bind</MenuItem>
<MenuItem value="volume">Volume</MenuItem>
</TextField>
</div>),
},
{
title: 'Source',
field: (r, k) => (
<div style={{fontWeight: 'bold', wordSpace: 'nowrap', overflow:'hidden', textOverflow:'ellipsis', maxWidth: '300px'}}>
{(r.Type == "bind") ?
<TextField
className="px-2 my-2"
variant="outlined"
name='Source'
id='Source'
style={{minWidth: '200px'}}
value={r.Source}
onChange={(e) => {
const newVolumes = [...formik.values.volumes];
newVolumes[k].Source = e.target.value;
formik.setFieldValue('volumes', newVolumes);
}}
/> :
<TextField
className="px-2 my-2"
variant="outlined"
name='Source'
id='Source'
select
style={{minWidth: '200px'}}
value={r.Source}
onChange={(e) => {
const newVolumes = [...formik.values.volumes];
newVolumes[k].Source = e.target.value;
formik.setFieldValue('volumes', newVolumes);
}}
>
{volumes.map((volume) => (
<MenuItem key={volume.Id} value={volume.Name}>
{volume.Name}
</MenuItem>
))}
</TextField>
}
</div>),
},
{
title: 'Target',
field: (r, k) => (
<div style={{fontWeight: 'bold', wordSpace: 'nowrap', overflow:'hidden', textOverflow:'ellipsis', maxWidth: '300px'}}>
<TextField
className="px-2 my-2"
variant="outlined"
name='Target'
id='Target'
style={{minWidth: '200px'}}
value={r.Target}
onChange={(e) => {
const newVolumes = [...formik.values.volumes];
newVolumes[k].Target = e.target.value;
formik.setFieldValue('volumes', newVolumes);
}}
/>
</div>),
},
{
title: '',
field: (r) => {
console.log(r);
return (<Button
variant="outlined"
color="primary"
onClick={() => {
const newVolumes = [...formik.values.volumes];
newVolumes.splice(newVolumes.indexOf(r), 1);
formik.setFieldValue('volumes', newVolumes);
}}>
Unmount
</Button>)
}
}
]}
/>}
{!volumes && (<div style={{ height: '550px' }}>
<center>
<br />
<CircularProgress />
</center>
</div>
)}
</Grid>
<Grid item xs={12}>
<Stack direction="column" spacing={2}>
{formik.errors.submit && (
<Grid item xs={12}>
<FormHelperText error>{formik.errors.submit}</FormHelperText>
</Grid>
)}
<LoadingButton
fullWidth
disableElevation
disabled={formik.errors.submit}
loading={formik.isSubmitting}
size="large"
type="submit"
variant="contained"
color="primary"
>
Update Volumes
</LoadingButton>
</Stack>
</Grid>
</Grid>
</MainCard>
</form>
)}
</Formik>
</div>
</Stack>);
};
export default VolumeContainerSetup;

View file

@ -0,0 +1,125 @@
// 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';
const NewNetworkButton = ({ fullWidth, refresh }) => {
const [isOpened, setIsOpened] = useState(false);
const formik = useFormik({
initialValues: {
name: '',
driver: 'bridge',
attachCosmos: false,
},
validationSchema: Yup.object({
name: Yup.string().required('Required'),
driver: Yup.string().required('Required'),
}),
onSubmit: (values, { setErrors, setStatus, setSubmitting }) => {
setSubmitting(true);
return API.docker.createNetwork(values)
.then((res) => {
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>New Network</DialogTitle>
<DialogContent>
<DialogContentText>
<form onSubmit={formik.handleSubmit}>
<Stack spacing={2} width={'300px'} style={{ marginTop: '10px' }}>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
style={{ marginBottom: '16px' }}
/>
<FormControl
fullWidth
variant="outlined"
error={formik.touched.driver && Boolean(formik.errors.driver)}
style={{ marginBottom: '16px' }}
>
<InputLabel htmlFor="driver">Driver</InputLabel>
<Select
id="driver"
name="driver"
value={formik.values.driver}
onChange={formik.handleChange}
label="Driver"
>
<MenuItem value="">
<em>None</em>
</MenuItem>
<MenuItem value="bridge">Bridge</MenuItem>
<MenuItem value="host">Host</MenuItem>
<MenuItem value="overlay">Overlay</MenuItem>
{/* Add more driver options if needed */}
</Select>
</FormControl>
<CosmosCheckbox
name="attachCosmos"
label="Attach to Cosmos"
formik={formik}
/>
</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}>
Create
</LoadingButton>
</DialogActions>}
</FormikProvider>
</Dialog>
<Button
fullWidth={fullWidth}
onClick={() => setIsOpened(true)}
startIcon={<PlusCircleOutlined />}
>
New Network
</Button>
</>;
};
export default NewNetworkButton;

View file

@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { FormikProvider, useFormik } from 'formik';
import * as Yup from 'yup';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Grid,
FormHelperText,
} from '@mui/material';
import { PlusCircleOutlined } from '@ant-design/icons';
import { LoadingButton } from '@mui/lab';
import * as API from '../../api';
const NewVolumeButton = ({ fullWidth, refresh }) => {
const [isOpened, setIsOpened] = useState(false);
const formik = useFormik({
initialValues: {
name: '',
driver: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Required'),
driver: Yup.string().required('Required'),
}),
onSubmit: (values, { setErrors, setStatus, setSubmitting }) => {
setSubmitting(true);
return API.docker.createVolume(values)
.then((res) => {
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>New Volume</DialogTitle>
<DialogContent>
<DialogContentText></DialogContentText>
<form onSubmit={formik.handleSubmit}>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
style={{ marginBottom: '16px' }}
/>
<FormControl
fullWidth
variant="outlined"
error={formik.touched.driver && Boolean(formik.errors.driver)}
style={{ marginBottom: '16px' }}
>
<InputLabel htmlFor="driver">Driver</InputLabel>
<Select
id="driver"
name="driver"
value={formik.values.driver}
onChange={formik.handleChange}
label="Driver"
>
<MenuItem value="">
<em>None</em>
</MenuItem>
<MenuItem value="local">Local</MenuItem>
{/* Add more driver options if needed */}
</Select>
</FormControl>
</form>
{formik.errors.submit && (
<Grid item xs={12}>
<FormHelperText error>{formik.errors.submit}</FormHelperText>
</Grid>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setIsOpened(false)}>Cancel</Button>
<LoadingButton
onClick={formik.handleSubmit}
loading={formik.isSubmitting}
>
Create
</LoadingButton>
</DialogActions>
</FormikProvider>
</Dialog>
<Button
fullWidth={fullWidth}
onClick={() => setIsOpened(true)}
startIcon={<PlusCircleOutlined />}
>
New Volume
</Button>
</>
);
};
export default NewVolumeButton;

View file

@ -5,6 +5,45 @@ import { useEffect, useState } from 'react';
import * as API from '../../api'; import * as API from '../../api';
import PrettyTableView from '../../components/tableView/prettyTableView'; import PrettyTableView from '../../components/tableView/prettyTableView';
import NewNetworkButton from './createNetwork';
export const NetworksColumns = (theme, isDark) => [
{
title: 'Network Name',
field: (r) => <Stack direction='column'>
<div style={{display:'inline-block', textDecoration: 'inherit', fontSize:'125%', color: isDark ? theme.palette.primary.light : theme.palette.primary.dark}}>{r.Name}</div><br/>
<div style={{display:'inline-block', textDecoration: 'inherit', fontSize: '90%', opacity: '90%'}}>{r.Driver} driver</div>
</Stack>,
search: (r) => r.Name,
},
{
title: 'Properties',
screenMin: 'md',
field: (r) => (
<Stack direction="row" spacing={1}>
<Chip label={r.Scope} color="primary" />
{r.Internal && <Chip label="Internal" color="secondary" />}
{r.Attachable && <Chip label="Attachable" color="success" />}
{r.Ingress && <Chip label="Ingress" color="warning" />}
</Stack>
),
},
{
title: 'IPAM gateway / mask',
screenMin: 'lg',
field: (r) => r.IPAM.Config.map((config, index) => (
<Stack key={index}>
<div>{config.Gateway}</div>
<div>{config.Subnet}</div>
</Stack>
)),
},
{
title: 'Created At',
screenMin: 'lg',
field: (r) => new Date(r.Created).toLocaleString(),
},
];
const NetworkManagementList = () => { const NetworkManagementList = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -45,43 +84,13 @@ const NetworkManagementList = () => {
{!isLoading && rows && ( {!isLoading && rows && (
<PrettyTableView <PrettyTableView
data={rows} data={rows}
buttons={[
<NewNetworkButton refresh={refresh} />,
]}
onRowClick={() => { }} onRowClick={() => { }}
getKey={(r) => r.Id} getKey={(r) => r.Id}
columns={[ columns={[
{ ...NetworksColumns(theme, isDark),
title: 'Network Name',
field: (r) => <Stack direction='column'>
<div style={{display:'inline-block', textDecoration: 'inherit', fontSize:'125%', color: isDark ? theme.palette.primary.light : theme.palette.primary.dark}}>{r.Name}</div><br/>
<div style={{display:'inline-block', textDecoration: 'inherit', fontSize: '90%', opacity: '90%'}}>{r.Driver} driver</div>
</Stack>,
search: (r) => r.Name,
},
{
title: 'Properties',
screenMin: 'md',
field: (r) => (
<Stack direction="row" spacing={1}>
<Chip label={r.Scope} color="primary" />
{r.Internal && <Chip label="Internal" color="secondary" />}
{r.Attachable && <Chip label="Attachable" color="success" />}
{r.Ingress && <Chip label="Ingress" color="warning" />}
</Stack>
),
},
{
title: 'IPAM gateway / mask',
screenMin: 'lg',
field: (r) => r.IPAM.Config.map((config, index) => (
<div key={index}>
{config.Gateway} / {config.Subnet}
</div>
)),
},
{
title: 'Created At',
screenMin: 'lg',
field: (r) => new Date(r.Created).toLocaleString(),
},
{ {
title: '', title: '',
clickable: true, clickable: true,

View file

@ -14,6 +14,7 @@ import RouteManagement from '../config/routes/routeman';
import { ValidateRoute, getFaviconURL, sanitizeRoute } from '../../utils/routes'; import { ValidateRoute, getFaviconURL, sanitizeRoute } from '../../utils/routes';
import HostChip from '../../components/hostChip'; import HostChip from '../../components/hostChip';
import PrettyTableView from '../../components/tableView/prettyTableView'; import PrettyTableView from '../../components/tableView/prettyTableView';
import NewVolumeButton from './createVolumes';
const VolumeManagementList = () => { const VolumeManagementList = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -56,6 +57,9 @@ const VolumeManagementList = () => {
data={rows} data={rows}
onRowClick={() => {}} onRowClick={() => {}}
getKey={(r) => r.Name} getKey={(r) => r.Name}
buttons={[
<NewVolumeButton refresh={refresh} />,
]}
columns={[ columns={[
{ {
title: 'Volume Name', title: 'Volume Name',

View file

@ -1,6 +1,6 @@
{ {
"name": "cosmos-server", "name": "cosmos-server",
"version": "0.4.0-unstable4", "version": "0.4.0-unstable5",
"description": "", "description": "",
"main": "test-server.js", "main": "test-server.js",
"bugs": { "bugs": {

View file

@ -77,4 +77,206 @@ func DeleteNetworkRoute(w http.ResponseWriter, req *http.Request) {
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return return
} }
}
func NetworkContainerRoutes(w http.ResponseWriter, req *http.Request) {
if req.Method == "GET" {
ListContainerNetworks(w, req)
} else if req.Method == "DELETE" {
DetachNetwork(w, req)
} else if req.Method == "POST" {
AttachNetwork(w, req)
} else {
utils.Error("NetworkContainerRoutes: Method not allowed " + req.Method, nil)
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return
}
}
func NetworkRoutes(w http.ResponseWriter, req *http.Request) {
if req.Method == "GET" {
ListNetworksRoute(w, req)
} else if req.Method == "POST" {
CreateNetworkRoute(w, req)
} else {
utils.Error("NetworkContainerRoutes: Method not allowed " + req.Method, nil)
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return
}
}
func AttachNetwork(w http.ResponseWriter, req *http.Request) {
if utils.AdminOnly(w, req) != nil {
return
}
if req.Method == "POST" {
vars := mux.Vars(req)
containerID := vars["containerId"]
networkID := vars["networkId"]
errD := Connect()
if errD != nil {
utils.Error("AttachNetwork", errD)
utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "AN001")
return
}
err := DockerClient.NetworkConnect(context.Background(), networkID, containerID, nil)
if err != nil {
utils.Error("AttachNetwork: Error while attaching network", err)
utils.HTTPError(w, "Network Attach Error: "+err.Error(), http.StatusInternalServerError, "AN002")
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "OK",
"message": "Network attached successfully",
})
} else {
utils.Error("AttachNetwork: Method not allowed "+req.Method, nil)
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return
}
}
func DetachNetwork(w http.ResponseWriter, req *http.Request) {
if utils.AdminOnly(w, req) != nil {
return
}
if req.Method == "DELETE" {
vars := mux.Vars(req)
containerID := vars["containerId"]
networkID := vars["networkId"]
errD := Connect()
if errD != nil {
utils.Error("DetachNetwork", errD)
utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "DN001")
return
}
err := DockerClient.NetworkDisconnect(context.Background(), networkID, containerID, true)
if err != nil {
utils.Error("DetachNetwork: Error while detaching network", err)
utils.HTTPError(w, "Network Detach Error: "+err.Error(), http.StatusInternalServerError, "DN002")
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "OK",
"message": "Network detached successfully",
})
} else {
utils.Error("DetachNetwork: Method not allowed "+req.Method, nil)
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return
}
}
func ListContainerNetworks(w http.ResponseWriter, req *http.Request) {
if utils.AdminOnly(w, req) != nil {
return
}
if req.Method == "GET" {
vars := mux.Vars(req)
containerID := vars["containerId"]
errD := Connect()
if errD != nil {
utils.Error("ListContainerNetworks", errD)
utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "LN001")
return
}
// List Docker networks
networks, err := DockerClient.NetworkList(context.Background(), types.NetworkListOptions{})
if err != nil {
utils.Error("ListNetworksRoute: Error while getting networks", err)
utils.HTTPError(w, "Networks Get Error: " + err.Error(), http.StatusInternalServerError, "LN002")
return
}
container, err := DockerClient.ContainerInspect(context.Background(), containerID)
if err != nil {
utils.Error("ListContainerNetworks: Error while getting container", err)
utils.HTTPError(w, "Container Get Error: "+err.Error(), http.StatusInternalServerError, "LN002")
return
}
containerNetworks := container.NetworkSettings.Networks
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "OK",
"data": map[string]interface{}{
"networks": networks,
"containerNetworks": containerNetworks,
},
})
} else {
utils.Error("ListContainerNetworks: Method not allowed " + req.Method, nil)
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return
}
}
type createNetworkPayload struct {
Name string `json:"name"`
Driver string `json:"driver"`
AttachCosmos bool `json:"attachCosmos"`
}
func CreateNetworkRoute(w http.ResponseWriter, req *http.Request) {
if utils.AdminOnly(w, req) != nil {
return
}
if req.Method == "POST" {
errD := Connect()
if errD != nil {
utils.Error("CreateNetworkRoute", errD)
utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "CN001")
return
}
var payload createNetworkPayload
err := json.NewDecoder(req.Body).Decode(&payload)
if err != nil {
utils.Error("CreateNetworkRoute: Error reading request body", err)
utils.HTTPError(w, "Error reading request body: "+err.Error(), http.StatusBadRequest, "CN002")
return
}
networkCreate := types.NetworkCreate{
CheckDuplicate: true,
Driver: payload.Driver,
}
resp, err := DockerClient.NetworkCreate(context.Background(), payload.Name, networkCreate)
if err != nil {
utils.Error("CreateNetworkRoute: Error while creating network", err)
utils.HTTPError(w, "Network Create Error: " + err.Error(), http.StatusInternalServerError, "CN004")
return
}
if payload.AttachCosmos {
// Attach network to cosmos
err = AttachNetworkToCosmos(resp.ID)
if err != nil {
utils.Error("CreateNetworkRoute: Error while attaching network to cosmos", err)
utils.HTTPError(w, "Network Attach Error: " + err.Error(), http.StatusInternalServerError, "CN005")
return
}
}
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "OK",
"data": resp,
})
} else {
utils.Error("CreateNetworkRoute: Method not allowed " + req.Method, nil)
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return
}
} }

View file

@ -0,0 +1,96 @@
package docker
import (
"encoding/json"
"net/http"
"github.com/azukaar/cosmos-server/src/utils"
containerType "github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
"github.com/docker/docker/api/types/mount"
"github.com/gorilla/mux"
)
type ContainerForm struct {
Image string `json:"image"`
RestartPolicy string `json:"restartPolicy"`
Env []string `json:"envVars"`
Labels map[string]string `json:"labels"`
PortBindings nat.PortMap `json:"portBindings"`
Volumes []mount.Mount `json:"Volumes"`
}
func UpdateContainerRoute(w http.ResponseWriter, req *http.Request) {
if utils.AdminOnly(w, req) != nil {
return
}
utils.Log("UpdateContainer" + "Updating container")
if req.Method == "POST" {
errD := Connect()
if errD != nil {
utils.Error("UpdateContainer", errD)
utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "DS002")
return
}
vars := mux.Vars(req)
containerName := utils.Sanitize(vars["containerId"])
container, err := DockerClient.ContainerInspect(DockerContext, containerName)
if err != nil {
utils.Error("UpdateContainer", err)
utils.HTTPError(w, "Internal server error: "+err.Error(), http.StatusInternalServerError, "DS002")
return
}
var form ContainerForm
err = json.NewDecoder(req.Body).Decode(&form)
if err != nil {
utils.Error("UpdateContainer", err)
utils.HTTPError(w, "Invalid JSON", http.StatusBadRequest, "DS003")
return
}
// Update container settings
if(form.Image != "") {
container.Config.Image = form.Image
}
if(form.RestartPolicy != "") {
container.HostConfig.RestartPolicy = containerType.RestartPolicy{Name: form.RestartPolicy}
}
if(form.Env != nil) {
container.Config.Env = form.Env
}
if(form.Labels != nil) {
container.Config.Labels = form.Labels
}
if(form.PortBindings != nil) {
container.HostConfig.PortBindings = form.PortBindings
container.Config.ExposedPorts = make(map[nat.Port]struct{})
for port := range form.PortBindings {
container.Config.ExposedPorts[port] = struct{}{}
}
}
if(form.Volumes != nil) {
container.HostConfig.Mounts = form.Volumes
container.HostConfig.Binds = []string{}
}
_, err = EditContainer(container.ID, container)
if err != nil {
utils.Error("UpdateContainer: EditContainer", err)
utils.HTTPError(w, "Internal server error: "+err.Error(), http.StatusInternalServerError, "DS004")
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "OK",
})
} else {
utils.Error("UpdateContainer: Method not allowed " + req.Method, nil)
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return
}
}

View file

@ -8,6 +8,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/azukaar/cosmos-server/src/utils" "github.com/azukaar/cosmos-server/src/utils"
filters "github.com/docker/docker/api/types/filters" filters "github.com/docker/docker/api/types/filters"
volumeTypes"github.com/docker/docker/api/types/volume"
) )
func ListVolumeRoute(w http.ResponseWriter, req *http.Request) { func ListVolumeRoute(w http.ResponseWriter, req *http.Request) {
@ -76,4 +77,66 @@ func DeleteVolumeRoute(w http.ResponseWriter, req *http.Request) {
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return return
} }
}
type VolumeCreateRequest struct {
Name string `json:"name"`
Driver string `json:"driver"`
}
func CreateVolumeRoute(w http.ResponseWriter, req *http.Request) {
if utils.AdminOnly(w, req) != nil {
return
}
if req.Method == "POST" {
errD := Connect()
if errD != nil {
utils.Error("CreateVolumeRoute", errD)
utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "CV001")
return
}
var payload VolumeCreateRequest
err := json.NewDecoder(req.Body).Decode(&payload)
if err != nil {
utils.Error("CreateNetworkRoute: Error reading request body", err)
utils.HTTPError(w, "Error reading request body: "+err.Error(), http.StatusBadRequest, "CN002")
return
}
// Create Docker volume with the provided options
volumeOptions := volumeTypes.VolumeCreateBody{
Name: payload.Name,
Driver: payload.Driver,
}
volume, err := DockerClient.VolumeCreate(context.Background(), volumeOptions)
if err != nil {
utils.Error("CreateVolumeRoute: Error while creating volume", err)
utils.HTTPError(w, "Volume creation error: "+err.Error(), http.StatusInternalServerError, "CV004")
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "OK",
"data": volume,
})
} else {
utils.Error("CreateVolumeRoute: Method not allowed " + req.Method, nil)
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return
}
}
func VolumesRoute(w http.ResponseWriter, req *http.Request) {
if req.Method == "GET" {
ListVolumeRoute(w, req)
} else if req.Method == "POST" {
CreateVolumeRoute(w, req)
} else {
utils.Error("VolumesRoute: Method not allowed " + req.Method, nil)
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
return
}
} }

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"time" "time"
"fmt"
"github.com/azukaar/cosmos-server/src/utils" "github.com/azukaar/cosmos-server/src/utils"
"github.com/docker/docker/client" "github.com/docker/docker/client"
@ -79,55 +80,73 @@ func Connect() error {
return nil return nil
} }
func EditContainer(containerID string, newConfig types.ContainerJSON) (string, error) { func EditContainer(oldContainerID string, newConfig types.ContainerJSON) (string, error) {
DockerNetworkLock <- true
defer func() { utils.Debug("VOLUMES:" + fmt.Sprintf("%v", newConfig.HostConfig.Mounts))
<-DockerNetworkLock
utils.Debug("Unlocking EDIT Container") if(oldContainerID != "") {
}() // no need to re-lock if we are reverting
DockerNetworkLock <- true
defer func() {
<-DockerNetworkLock
utils.Debug("Unlocking EDIT Container")
}()
errD := Connect() errD := Connect()
if errD != nil { if errD != nil {
return "", errD return "", errD
}
utils.Log("EditContainer - Container updating " + containerID)
// get container informations
// https://godoc.org/github.com/docker/docker/api/types#ContainerJSON
oldContainer, err := DockerClient.ContainerInspect(DockerContext, containerID)
if err != nil {
return "", err
}
// if no name, use the same one, that will force Docker to create a hostname if not set
newName := oldContainer.Name
newConfig.Config.Hostname = newName
// stop and remove container
stopError := DockerClient.ContainerStop(DockerContext, containerID, container.StopOptions{})
if stopError != nil {
return "", stopError
}
removeError := DockerClient.ContainerRemove(DockerContext, containerID, types.ContainerRemoveOptions{})
if removeError != nil {
return "", removeError
}
// wait for container to be destroyed
//
for {
_, err := DockerClient.ContainerInspect(DockerContext, containerID)
if err != nil {
break
} else {
utils.Log("EditContainer - Waiting for container to be destroyed")
time.Sleep(1 * time.Second)
} }
} }
newName := newConfig.Name
oldContainer := newConfig
utils.Log("EditContainer - Container stopped " + containerID) if(oldContainerID != "") {
utils.Log("EditContainer - Container updating. Retriveing currently running " + oldContainerID)
var err error
// get container informations
// https://godoc.org/github.com/docker/docker/api/types#ContainerJSON
oldContainer, err = DockerClient.ContainerInspect(DockerContext, oldContainerID)
utils.Debug("OLD VOLUMES:" + fmt.Sprintf("%v", oldContainer.HostConfig.Mounts))
if err != nil {
return "", err
}
// if no name, use the same one, that will force Docker to create a hostname if not set
newName = oldContainer.Name
newConfig.Config.Hostname = newName
// stop and remove container
stopError := DockerClient.ContainerStop(DockerContext, oldContainerID, container.StopOptions{})
if stopError != nil {
return "", stopError
}
removeError := DockerClient.ContainerRemove(DockerContext, oldContainerID, types.ContainerRemoveOptions{})
if removeError != nil {
return "", removeError
}
// wait for container to be destroyed
//
for {
_, err := DockerClient.ContainerInspect(DockerContext, oldContainerID)
if err != nil {
break
} else {
utils.Log("EditContainer - Waiting for container to be destroyed")
time.Sleep(1 * time.Second)
}
}
utils.Log("EditContainer - Container stopped " + oldContainerID)
} else {
utils.Log("EditContainer - Revert started")
}
// recreate container with new informations // recreate container with new informations
createResponse, createError := DockerClient.ContainerCreate( createResponse, createError := DockerClient.ContainerCreate(
@ -139,6 +158,8 @@ func EditContainer(containerID string, newConfig types.ContainerJSON) (string, e
newName, newName,
) )
utils.Log("EditContainer - Container recreated. Re-connecting networks " + createResponse.ID)
// is force secure // is force secure
isForceSecure := newConfig.Config.Labels["cosmos-force-network-secured"] == "true" isForceSecure := newConfig.Config.Labels["cosmos-force-network-secured"] == "true"
@ -148,6 +169,7 @@ func EditContainer(containerID string, newConfig types.ContainerJSON) (string, e
utils.Log("EditContainer - Skipping network " + networkName + " (cosmos-force-network-secured is true)") utils.Log("EditContainer - Skipping network " + networkName + " (cosmos-force-network-secured is true)")
continue continue
} }
utils.Log("EditContainer - Connecting to network " + networkName)
errNet := ConnectToNetworkSync(networkName, createResponse.ID) errNet := ConnectToNetworkSync(networkName, createResponse.ID)
if errNet != nil { if errNet != nil {
utils.Error("EditContainer - Failed to connect to network " + networkName, errNet) utils.Error("EditContainer - Failed to connect to network " + networkName, errNet)
@ -155,25 +177,57 @@ func EditContainer(containerID string, newConfig types.ContainerJSON) (string, e
utils.Debug("EditContainer - New Container connected to network " + networkName) utils.Debug("EditContainer - New Container connected to network " + networkName)
} }
} }
utils.Log("EditContainer - Networks Connected. Starting new container " + createResponse.ID)
runError := DockerClient.ContainerStart(DockerContext, createResponse.ID, types.ContainerStartOptions{}) runError := DockerClient.ContainerStart(DockerContext, createResponse.ID, types.ContainerStartOptions{})
if runError != nil { if createError != nil || runError != nil {
return "", runError if(oldContainerID == "") {
} if(createError == nil) {
utils.Error("EditContainer - Failed to revert. Container is re-created but in broken state.", runError)
utils.Log("EditContainer - Container recreated " + createResponse.ID) return "", runError
} else {
if createError != nil { utils.Error("EditContainer - Failed to revert. Giving up.", createError)
// attempt to restore container return "", createError
_, restoreError := DockerClient.ContainerCreate(DockerContext, oldContainer.Config, nil, nil, nil, oldContainer.Name) }
if restoreError != nil {
utils.Error("EditContainer - Failed to restore Docker Container after update failure", restoreError)
} }
return "", createError utils.Log("EditContainer - Failed to edit, attempting to revert changes")
if(createError == nil) {
utils.Log("EditContainer - Killing new broken container")
DockerClient.ContainerKill(DockerContext, createResponse.ID, "")
}
utils.Log("EditContainer - Reverting...")
// attempt to restore container
restored, restoreError := EditContainer("", oldContainer)
if restoreError != nil {
utils.Error("EditContainer - Failed to restore container", restoreError)
if createError != nil {
utils.Error("EditContainer - re-create container ", createError)
return "", createError
} else {
utils.Error("EditContainer - re-start container ", runError)
return "", runError
}
} else {
utils.Log("EditContainer - Container restored " + oldContainerID)
errorWas := ""
if createError != nil {
errorWas = createError.Error()
} else {
errorWas = runError.Error()
}
return restored, errors.New("Failed to edit container, but restored to previous state. Error was: " + errorWas)
}
} }
utils.Log("EditContainer - Container started. All done! " + createResponse.ID)
return createResponse.ID, nil return createResponse.ID, nil
} }

View file

@ -52,20 +52,24 @@ func CreateCosmosNetwork() (string, error) {
return "", err return "", err
} }
//if running in Docker, connect to main network
// utils.Debug("HOSTNAME: " + os.Getenv("HOSTNAME"))
// if os.Getenv("HOSTNAME") != "" {
// err := DockerClient.NetworkConnect(DockerContext, newNeworkName, os.Getenv("HOSTNAME"), &network.EndpointSettings{})
// if err != nil {
// utils.Error("Docker Network Connect", err)
// return "", err
// }
// }
return newNeworkName, nil return newNeworkName, nil
} }
func AttachNetworkToCosmos(newNeworkName string ) error {
utils.Log("Connecting Cosmos to network " + newNeworkName)
utils.Debug("HOSTNAME: " + os.Getenv("HOSTNAME"))
if os.Getenv("HOSTNAME") != "" {
err := DockerClient.NetworkConnect(DockerContext, newNeworkName, os.Getenv("HOSTNAME"), &network.EndpointSettings{})
if err != nil {
utils.Error("Docker Network Connect", err)
return err
}
return nil
}
return nil
}
func ConnectToSecureNetwork(containerConfig types.ContainerJSON) (bool, error) { func ConnectToSecureNetwork(containerConfig types.ContainerJSON) (bool, error) {
errD := Connect() errD := Connect()
if errD != nil { if errD != nil {

View file

@ -219,15 +219,18 @@ func StartServer() {
srapi.HandleFunc("/api/users", user.UsersRoute) srapi.HandleFunc("/api/users", user.UsersRoute)
srapi.HandleFunc("/api/volume/{volumeName}", docker.DeleteVolumeRoute) srapi.HandleFunc("/api/volume/{volumeName}", docker.DeleteVolumeRoute)
srapi.HandleFunc("/api/volumes", docker.ListVolumeRoute) srapi.HandleFunc("/api/volumes", docker.VolumesRoute)
srapi.HandleFunc("/api/network/{networkID}", docker.DeleteNetworkRoute) srapi.HandleFunc("/api/network/{networkID}", docker.DeleteNetworkRoute)
srapi.HandleFunc("/api/networks", docker.ListNetworksRoute) srapi.HandleFunc("/api/networks", docker.NetworkRoutes)
srapi.HandleFunc("/api/servapps/{containerId}/manage/{action}", docker.ManageContainerRoute) srapi.HandleFunc("/api/servapps/{containerId}/manage/{action}", docker.ManageContainerRoute)
srapi.HandleFunc("/api/servapps/{containerId}/secure/{status}", docker.SecureContainerRoute) srapi.HandleFunc("/api/servapps/{containerId}/secure/{status}", docker.SecureContainerRoute)
srapi.HandleFunc("/api/servapps/{containerId}/logs", docker.GetContainerLogsRoute) srapi.HandleFunc("/api/servapps/{containerId}/logs", docker.GetContainerLogsRoute)
srapi.HandleFunc("/api/servapps/{containerId}/update", docker.UpdateContainerRoute)
srapi.HandleFunc("/api/servapps/{containerId}/", docker.GetContainerRoute) srapi.HandleFunc("/api/servapps/{containerId}/", docker.GetContainerRoute)
srapi.HandleFunc("/api/servapps/{containerId}/network/{networkId}", docker.NetworkContainerRoutes)
srapi.HandleFunc("/api/servapps/{containerId}/networks", docker.NetworkContainerRoutes)
srapi.HandleFunc("/api/servapps", docker.ContainersRoute) srapi.HandleFunc("/api/servapps", docker.ContainersRoute)
if(!config.HTTPConfig.AcceptAllInsecureHostname) { if(!config.HTTPConfig.AcceptAllInsecureHostname) {

View file

@ -3,6 +3,7 @@ package main
import ( import (
"math/rand" "math/rand"
"time" "time"
"context"
"github.com/azukaar/cosmos-server/src/docker" "github.com/azukaar/cosmos-server/src/docker"
"github.com/azukaar/cosmos-server/src/utils" "github.com/azukaar/cosmos-server/src/utils"
@ -23,5 +24,12 @@ func main() {
docker.BootstrapAllContainersFromTags() docker.BootstrapAllContainersFromTags()
version, err := docker.DockerClient.ServerVersion(context.Background())
if err != nil {
panic(err)
}
utils.Log("Docker API version: " + version.APIVersion)
StartServer() StartServer()
} }

View file

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"time" "time"
"net" "net"
"strings"
"fmt" "fmt"
"github.com/mxk/go-flowrate/flowrate" "github.com/mxk/go-flowrate/flowrate"
@ -180,18 +181,13 @@ func EnsureHostname(next http.Handler) http.Handler {
return return
} }
port := ""
if (IsHTTPS && MainConfig.HTTPConfig.HTTPSPort != "443") {
port = ":" + MainConfig.HTTPConfig.HTTPSPort
} else if (!IsHTTPS && MainConfig.HTTPConfig.HTTPPort != "80") {
port = ":" + MainConfig.HTTPConfig.HTTPPort
}
hostnames := GetAllHostnames() hostnames := GetAllHostnames()
reqHostNoPort := strings.Split(r.Host, ":")[0]
isOk := false isOk := false
for _, hostname := range hostnames { for _, hostname := range hostnames {
if r.Host == hostname + port { if reqHostNoPort == hostname {
isOk = true isOk = true
} }
} }