Support updating docker image for a server from the frontend

This commit is contained in:
Dane Everitt 2020-12-13 11:07:29 -08:00
parent 1dacd703df
commit 5bbb36b3cf
No known key found for this signature in database
GPG Key ID: EEA66103B3D71F53
12 changed files with 171 additions and 19 deletions

View File

@ -2,13 +2,16 @@
namespace Pterodactyl\Http\Controllers\Api\Client\Servers; namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Services\Servers\ReinstallServerService; use Pterodactyl\Services\Servers\ReinstallServerService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest;
class SettingsController extends ClientApiController class SettingsController extends ClientApiController
@ -73,4 +76,26 @@ class SettingsController extends ClientApiController
return new JsonResponse([], Response::HTTP_ACCEPTED); return new JsonResponse([], Response::HTTP_ACCEPTED);
} }
/**
* Changes the Docker image in use by the server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest $request
* @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\JsonResponse
*
* @throws \Throwable
*/
public function dockerImage(SetDockerImageRequest $request, Server $server)
{
if (!in_array($server->image, $server->egg->docker_images)) {
throw new BadRequestHttpException(
'This server\'s Docker image has been manually set by an administrator and cannot be updated.'
);
}
$server->forceFill(['image' => $request->input('docker_image')])->saveOrFail();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
} }

View File

@ -62,6 +62,7 @@ class StartupController extends ClientApiController
->transformWith($this->getTransformer(EggVariableTransformer::class)) ->transformWith($this->getTransformer(EggVariableTransformer::class))
->addMeta([ ->addMeta([
'startup_command' => $startup, 'startup_command' => $startup,
'docker_images' => $server->egg->docker_images,
'raw_startup_command' => $server->startup, 'raw_startup_command' => $server->startup,
]) ])
->toArray(); ->toArray();

View File

@ -0,0 +1,36 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Settings;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use Illuminate\Validation\Rule;
use Pterodactyl\Models\Permission;
use Pterodactyl\Contracts\Http\ClientPermissionsRequest;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class SetDockerImageRequest extends ClientApiRequest implements ClientPermissionsRequest
{
/**
* @return string
*/
public function permission(): string
{
return Permission::ACTION_STARTUP_DOCKER_IMAGE;
}
/**
* @return array[]
*/
public function rules(): array
{
/** @var \Pterodactyl\Models\Server $server */
$server = $this->route()->parameter('server');
Assert::isInstanceOf($server, Server::class);
return [
'docker_image' => ['required', 'string', Rule::in($server->egg->docker_images)],
];
}
}

View File

@ -58,6 +58,7 @@ class Permission extends Model
const ACTION_STARTUP_READ = 'startup.read'; const ACTION_STARTUP_READ = 'startup.read';
const ACTION_STARTUP_UPDATE = 'startup.update'; const ACTION_STARTUP_UPDATE = 'startup.update';
const ACTION_STARTUP_DOCKER_IMAGE = 'startup.docker-image';
const ACTION_SETTINGS_RENAME = 'settings.rename'; const ACTION_SETTINGS_RENAME = 'settings.rename';
const ACTION_SETTINGS_REINSTALL = 'settings.reinstall'; const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
@ -176,6 +177,7 @@ class Permission extends Model
'keys' => [ 'keys' => [
'read' => 'Allows a user to view the startup variables for a server.', 'read' => 'Allows a user to view the startup variables for a server.',
'update' => 'Allows a user to modify the startup variables for the server.', 'update' => 'Allows a user to modify the startup variables for the server.',
'docker-image' => 'Allows a user to modify the Docker image used when running the server.',
], ],
], ],

View File

@ -63,6 +63,7 @@ class ServerTransformer extends BaseClientTransformer
'cpu' => $server->cpu, 'cpu' => $server->cpu,
], ],
'invocation' => $service->handle($server, ! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $server)), 'invocation' => $service->handle($server, ! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $server)),
'docker_image' => $server->image,
'egg_features' => $server->egg->inherit_features, 'egg_features' => $server->egg->inherit_features,
'feature_limits' => [ 'feature_limits' => [
'databases' => $server->database_limit, 'databases' => $server->database_limit,

View File

@ -22,6 +22,7 @@ export interface Server {
port: number; port: number;
}; };
invocation: string; invocation: string;
dockerImage: string;
description: string; description: string;
limits: { limits: {
memory: number; memory: number;
@ -50,6 +51,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
name: data.name, name: data.name,
node: data.node, node: data.node,
invocation: data.invocation, invocation: data.invocation,
dockerImage: data.docker_image,
sftpDetails: { sftpDetails: {
ip: data.sftp_details.ip, ip: data.sftp_details.ip,
port: data.sftp_details.port, port: data.sftp_details.port,

View File

@ -0,0 +1,5 @@
import http from '@/api/http';
export default async (uuid: string, image: string): Promise<void> => {
await http.put(`/api/client/servers/${uuid}/settings/docker-image`, { docker_image: image });
};

View File

@ -6,6 +6,7 @@ import { ServerEggVariable } from '@/api/server/types';
interface Response { interface Response {
invocation: string; invocation: string;
variables: ServerEggVariable[]; variables: ServerEggVariable[];
dockerImages: string[];
} }
export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startup' ], async (): Promise<Response> => { export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startup' ], async (): Promise<Response> => {
@ -13,5 +14,5 @@ export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startu
const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable); const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable);
return { invocation: data.meta.startup_command, variables }; return { invocation: data.meta.startup_command, variables, dockerImages: data.meta.docker_images || [] };
}, { initialData, errorRetryCount: 3 }); }, { initialData, errorRetryCount: 3 });

View File

@ -2,16 +2,28 @@ import React from 'react';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
import Fade from '@/components/elements/Fade'; import Fade from '@/components/elements/Fade';
import tw from 'twin.macro'; import tw from 'twin.macro';
import styled, { css } from 'styled-components/macro';
import Select from '@/components/elements/Select';
const Container = styled.div<{ visible?: boolean }>`
${tw`relative`};
${props => props.visible && css`
& ${Select} {
background-image: none;
}
`};
`;
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => ( const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
<div css={tw`relative`}> <Container visible={visible}>
<Fade appear unmountOnExit in={visible} timeout={150}> <Fade appear unmountOnExit in={visible} timeout={150}>
<div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}> <div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}>
<Spinner size={'small'}/> <Spinner size={'small'}/>
</div> </div>
</Fade> </Fade>
{children} {children}
</div> </Container>
); );
export default InputSpinner; export default InputSpinner;

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import TitledGreyBox from '@/components/elements/TitledGreyBox'; import TitledGreyBox from '@/components/elements/TitledGreyBox';
import tw from 'twin.macro'; import tw from 'twin.macro';
import VariableBox from '@/components/server/startup/VariableBox'; import VariableBox from '@/components/server/startup/VariableBox';
@ -9,15 +9,32 @@ import ServerError from '@/components/screens/ServerError';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect'; import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect';
import Select from '@/components/elements/Select';
import isEqual from 'react-fast-compare';
import Input from '@/components/elements/Input';
import setSelectedDockerImage from '@/api/server/setSelectedDockerImage';
import InputSpinner from '@/components/elements/InputSpinner';
import useFlash from '@/plugins/useFlash';
const StartupContainer = () => { const StartupContainer = () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const [ loading, setLoading ] = useState(false);
const invocation = ServerContext.useStoreState(state => state.server.data!.invocation); const { clearFlashes, clearAndAddHttpError } = useFlash();
const variables = ServerContext.useStoreState(state => state.server.data!.variables);
const { data, error, isValidating, mutate } = getServerStartup(uuid, { invocation, variables }); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const variables = ServerContext.useStoreState(({ server }) => ({
variables: server.data!.variables,
invocation: server.data!.invocation,
dockerImage: server.data!.dockerImage,
// @ts-ignore
}), isEqual);
const { data, error, isValidating, mutate } = getServerStartup(uuid, {
...variables,
dockerImages: [ variables.dockerImage ],
});
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState); const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
const isCustomImage = data && !data.dockerImages.map(v => v.toLowerCase()).includes(variables.dockerImage.toLowerCase());
useEffect(() => { useEffect(() => {
// Since we're passing in initial data this will not trigger on mount automatically. We // Since we're passing in initial data this will not trigger on mount automatically. We
@ -36,6 +53,20 @@ const StartupContainer = () => {
})); }));
}, [ data ]); }, [ data ]);
const updateSelectedDockerImage = useCallback((v: React.ChangeEvent<HTMLSelectElement>) => {
setLoading(true);
clearFlashes('startup:image');
const image = v.currentTarget.value;
setSelectedDockerImage(uuid, image)
.then(() => setServerFromState(s => ({ ...s, dockerImage: image })))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'startup:image', error });
})
.then(() => setLoading(false));
}, [ uuid ]);
return ( return (
!data ? !data ?
(!error || (error && isValidating)) ? (!error || (error && isValidating)) ?
@ -47,15 +78,49 @@ const StartupContainer = () => {
onRetry={() => mutate()} onRetry={() => mutate()}
/> />
: :
<ServerContentBlock title={'Startup Settings'}> <ServerContentBlock title={'Startup Settings'} showFlashKey={'startup:image'}>
<TitledGreyBox title={'Startup Command'}> <div css={tw`flex`}>
<div css={tw`px-1 py-2`}> <TitledGreyBox title={'Startup Command'} css={tw`flex-1`}>
<p css={tw`font-mono bg-neutral-900 rounded py-2 px-4`}> <div css={tw`px-1 py-2`}>
{data.invocation} <p css={tw`font-mono bg-neutral-900 rounded py-2 px-4`}>
</p> {data.invocation}
</div> </p>
</TitledGreyBox> </div>
<div css={tw`grid gap-8 md:grid-cols-2 mt-10`}> </TitledGreyBox>
<TitledGreyBox title={'Docker Image'} css={tw`flex-1 lg:flex-none lg:w-1/3 ml-10`}>
{data.dockerImages.length > 1 && !isCustomImage ?
<>
<InputSpinner visible={loading}>
<Select
disabled={data.dockerImages.length < 2}
onChange={updateSelectedDockerImage}
defaultValue={variables.dockerImage}
>
{data.dockerImages.map(image => (
<option key={image} value={image}>{image}</option>
))}
</Select>
</InputSpinner>
<p css={tw`text-xs text-neutral-300 mt-2`}>
This is an advanced feature allowing you to select a Docker image to use when
running this server instance.
</p>
</>
:
<>
<Input disabled readOnly value={variables.dockerImage}/>
{isCustomImage &&
<p css={tw`text-xs text-neutral-300 mt-2`}>
This {'server\'s'} Docker image has been manually set by an administrator and cannot
be changed through this UI.
</p>
}
</>
}
</TitledGreyBox>
</div>
<h3 css={tw`mt-8 mb-2 text-2xl`}>Variables</h3>
<div css={tw`grid gap-8 md:grid-cols-2`}>
{data.variables.map(variable => <VariableBox key={variable.envVariable} variable={variable}/>)} {data.variables.map(variable => <VariableBox key={variable.envVariable} variable={variable}/>)}
</div> </div>
</ServerContentBlock> </ServerContentBlock>

View File

@ -32,8 +32,9 @@ const VariableBox = ({ variable }: Props) => {
updateStartupVariable(uuid, variable.envVariable, value) updateStartupVariable(uuid, variable.envVariable, value)
.then(([ response, invocation ]) => mutate(data => ({ .then(([ response, invocation ]) => mutate(data => ({
...data,
invocation, invocation,
variables: data.variables.map(v => v.envVariable === response.envVariable ? response : v), variables: (data.variables || []).map(v => v.envVariable === response.envVariable ? response : v),
}), false)) }), false))
.catch(error => { .catch(error => {
console.error(error); console.error(error);
@ -67,7 +68,7 @@ const VariableBox = ({ variable }: Props) => {
placeholder={variable.defaultValue} placeholder={variable.defaultValue}
/> />
</InputSpinner> </InputSpinner>
<p css={tw`mt-1 text-xs text-neutral-400`}> <p css={tw`mt-1 text-xs text-neutral-300`}>
{variable.description} {variable.description}
</p> </p>
</TitledGreyBox> </TitledGreyBox>

View File

@ -114,5 +114,6 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
Route::group(['prefix' => '/settings'], function () { Route::group(['prefix' => '/settings'], function () {
Route::post('/rename', 'Servers\SettingsController@rename'); Route::post('/rename', 'Servers\SettingsController@rename');
Route::post('/reinstall', 'Servers\SettingsController@reinstall'); Route::post('/reinstall', 'Servers\SettingsController@reinstall');
Route::put('/docker-image', 'Servers\SettingsController@dockerImage');
}); });
}); });