diff --git a/client/src/api/docker.jsx b/client/src/api/docker.jsx index 6661e1b..73adb85 100644 --- a/client/src/api/docker.jsx +++ b/client/src/api/docker.jsx @@ -220,6 +220,54 @@ function createService(serviceData, onProgress) { }); } +function pullImage(imageName, onProgress, ifMissing) { + const requestOptions = { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + }; + + const imageNameEncoded = encodeURIComponent(imageName); + + return fetch(`/cosmos/api/images/${ifMissing ? 'pull-if-missing' : 'pull'}?imageName=${imageNameEncoded}`, 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, @@ -240,4 +288,5 @@ export { attachTerminal, createTerminal, createService, + pullImage, }; \ No newline at end of file diff --git a/client/src/components/logsInModal.jsx b/client/src/components/logsInModal.jsx new file mode 100644 index 0000000..f4bfda3 --- /dev/null +++ b/client/src/components/logsInModal.jsx @@ -0,0 +1,88 @@ +// 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 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 { useEffect, useState } from 'react'; +import { smartDockerLogConcat, tryParseProgressLog } from '../utils/docker'; + +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', +} + +const LogsInModal = ({title, request, OnSuccess, OnError, closeAnytime, initialLogs = [], }) => { + const [openModal, setOpenModal] = useState(false); + const [logs, setLogs] = useState(initialLogs); + const [done, setDone] = useState(closeAnytime); + const preRef = React.useRef(null); + + useEffect(() => { + setLogs(initialLogs); + setDone(closeAnytime); + + if(request === null) return; + + request((newlog) => { + setLogs((old) => smartDockerLogConcat(old, newlog)); + + if(preRef.current) + preRef.current.scrollTop = preRef.current.scrollHeight; + + if (newlog.includes('[OPERATION SUCCEEDED]')) { + setDone(true); + setOpenModal(false); + OnSuccess && OnSuccess(); + } else if (newlog.includes('[OPERATION FAILED]')) { + setDone(true); + OnError && OnError(newlog); + } else { + setOpenModal(true); + } + }); + }, [request]); + + return <> + setOpenModal(false)}> + {title} + + +
+                    {logs.map((l) => {
+                      return 
{tryParseProgressLog(l)}
+ })} +
+
+
+ + +
+ ; +}; + +export default LogsInModal; diff --git a/client/src/pages/servapps/containers/setup.jsx b/client/src/pages/servapps/containers/setup.jsx index a795961..336d2d7 100644 --- a/client/src/pages/servapps/containers/setup.jsx +++ b/client/src/pages/servapps/containers/setup.jsx @@ -7,6 +7,7 @@ import { CosmosCheckbox, CosmosFormDivider, CosmosInputText, CosmosSelect } import { DeleteOutlined, PlusCircleOutlined } from '@ant-design/icons'; import * as API from '../../../api'; import { LoadingButton } from '@mui/lab'; +import LogsInModal from '../../../components/logsInModal'; const containerInfoFrom = (values) => { const labels = {}; @@ -33,9 +34,11 @@ const DockerContainerSetup = ({config, containerInfo, OnChange, refresh, newCont ['on-failure', 'Restart On Failure'], ['unless-stopped', 'Restart Unless Stopped'], ]; + const [pullRequest, setPullRequest] = React.useState(null); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const padding = isMobile ? '6px 4px' : '12px 10px'; + const [latestImage, setLatestImage] = React.useState(containerInfo.Config.Image); return (
@@ -73,6 +76,11 @@ const DockerContainerSetup = ({config, containerInfo, OnChange, refresh, newCont return errors; }} onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => { + if(values.image !== latestImage) { + setPullRequest(() => ((cb) => API.docker.pullImage(values.image,cb, true))); + return; + } + if(newContainer) return false; delete values.name; @@ -95,6 +103,14 @@ const DockerContainerSetup = ({config, containerInfo, OnChange, refresh, newCont > {(formik) => (
+ { + setPullRequest(null); + setLatestImage(formik.values.image); + }} + /> {containerInfo.State && containerInfo.State.Status !== 'running' && ( @@ -268,6 +284,9 @@ const DockerContainerSetup = ({config, containerInfo, OnChange, refresh, newCont {formik.errors.submit} )} + {formik.values.image !== latestImage && + You have updated the image. Clicking the button below will pull the new image, and then only can you update the container. + } - Update + {formik.values.image !== latestImage ? 'Pull New Image' : 'Update Container'} } diff --git a/package.json b/package.json index 0def8df..e1580ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cosmos-server", - "version": "0.5.0-unstable5", + "version": "0.5.0-unstable6", "description": "", "main": "test-server.js", "bugs": { diff --git a/src/docker/api_images.go b/src/docker/api_images.go index 11bc065..eaf2581 100644 --- a/src/docker/api_images.go +++ b/src/docker/api_images.go @@ -6,7 +6,6 @@ import ( "github.com/azukaar/cosmos-server/src/utils" - "github.com/gorilla/mux" ) func InspectImageRoute(w http.ResponseWriter, req *http.Request) { @@ -21,8 +20,7 @@ func InspectImageRoute(w http.ResponseWriter, req *http.Request) { return } - vars := mux.Vars(req) - imageName := utils.SanitizeSafe(vars["imageName"]) + imageName := utils.SanitizeSafe(req.URL.Query().Get("imageName")) utils.Log("InspectImage " + imageName) diff --git a/src/docker/api_update.go b/src/docker/api_update.go new file mode 100644 index 0000000..3e70674 --- /dev/null +++ b/src/docker/api_update.go @@ -0,0 +1,182 @@ +package docker + +import ( + "context" + "encoding/json" + "net/http" + "fmt" + "bufio" + + "github.com/docker/docker/api/types" + + "github.com/gorilla/mux" + "github.com/azukaar/cosmos-server/src/utils" +) + +func PullImageIfMissing(w http.ResponseWriter, req *http.Request) { + if utils.AdminOnly(w, req) != nil { + return + } + + imageName := utils.SanitizeSafe(req.URL.Query().Get("imageName")) + + if req.Method == "GET" { + errD := Connect() + if errD != nil { + utils.Error("PullImageIfMissing", errD) + utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "LN001") + return + } + + // 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 + } + + _, _, errImage := DockerClient.ImageInspectWithRaw(DockerContext, imageName) + if errImage != nil { + utils.Log("PullImageIfMissing - Image not found, pulling " + imageName) + fmt.Fprintf(w, "PullImageIfMissing - Image not found, pulling " + imageName + "\n") + flusher.Flush() + out, errPull := DockerClient.ImagePull(DockerContext, imageName, types.ImagePullOptions{}) + if errPull != nil { + utils.Error("PullImageIfMissing - Image not found.", errPull) + fmt.Fprintf(w, "[OPERATION FAILED] PullImageIfMissing - Image not found. " + errPull.Error() + "\n") + flusher.Flush() + return + } + defer out.Close() + + // wait for image pull to finish + scanner := bufio.NewScanner(out) + for scanner.Scan() { + utils.Log(scanner.Text()) + fmt.Fprintf(w, scanner.Text() + "\n") + flusher.Flush() + } + + utils.Log("PullImageIfMissing - Image pulled " + imageName) + fmt.Fprintf(w, "[OPERATION SUCCEEDED]") + flusher.Flush() + return + } + + utils.Log("PullImageIfMissing - Image found, skipping " + imageName) + fmt.Fprintf(w, "[OPERATION SUCCEEDED]") + flusher.Flush() + } else { + utils.Error("PullImageIfMissing: Method not allowed " + req.Method, nil) + utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") + return + } +} + +func PullImage(w http.ResponseWriter, req *http.Request) { + if utils.AdminOnly(w, req) != nil { + return + } + + imageName := utils.SanitizeSafe(req.URL.Query().Get("imageName")) + + if req.Method == "GET" { + errD := Connect() + if errD != nil { + utils.Error("PullImageIfMissing", errD) + utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "LN001") + return + } + + // 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.Log("PullImageIfMissing - Image not found, pulling " + imageName) + fmt.Fprintf(w, "PullImageIfMissing - Image not found, pulling " + imageName + "\n") + flusher.Flush() + out, errPull := DockerClient.ImagePull(DockerContext, imageName, types.ImagePullOptions{}) + if errPull != nil { + utils.Error("PullImageIfMissing - Image not found.", errPull) + fmt.Fprintf(w, "[OPERATION FAILED] PullImageIfMissing - Image not found. " + errPull.Error() + "\n") + flusher.Flush() + return + } + defer out.Close() + + // wait for image pull to finish + scanner := bufio.NewScanner(out) + for scanner.Scan() { + utils.Log(scanner.Text()) + fmt.Fprintf(w, scanner.Text() + "\n") + flusher.Flush() + } + + utils.Log("PullImageIfMissing - Image pulled " + imageName) + fmt.Fprintf(w, "[OPERATION SUCCEEDED]") + flusher.Flush() + return + } else { + utils.Error("PullImageIfMissing: Method not allowed " + req.Method, nil) + utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") + return + } +} + +func CanUpdateImageRoute(w http.ResponseWriter, req *http.Request) { + if utils.AdminOnly(w, req) != nil { + return + } + + vars := mux.Vars(req) + containerId := vars["containerId"] + + if req.Method == "GET" { + errD := Connect() + if errD != nil { + utils.Error("CanUpdateImageRoute", errD) + utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "LN001") + return + } + + // get Docker container + container, err := DockerClient.ContainerInspect(context.Background(), containerId) + if err != nil { + utils.Error("CanUpdateImageRoute: Error while getting container", err) + utils.HTTPError(w, "Container Get Error: " + err.Error(), http.StatusInternalServerError, "LN002") + return + } + + // check if the container's image can be updated + canUpdate := false + imageName := container.Image + image, _, err := DockerClient.ImageInspectWithRaw(context.Background(), imageName) + if err != nil { + utils.Error("CanUpdateImageRoute: Error while inspecting image", err) + utils.HTTPError(w, "Image Inspection Error: " + err.Error(), http.StatusInternalServerError, "LN003") + return + } + if len(image.RepoDigests) > 0 { + canUpdate = true + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "OK", + "data": canUpdate, + }) + } else { + utils.Error("CanUpdateImageRoute: Method not allowed " + req.Method, nil) + utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") + return + } +} + diff --git a/src/docker/docker.go b/src/docker/docker.go index c53e6fe..bc62cfa 100644 --- a/src/docker/docker.go +++ b/src/docker/docker.go @@ -5,6 +5,7 @@ import ( "errors" "time" "fmt" + "bufio" "github.com/azukaar/cosmos-server/src/utils" "github.com/docker/docker/client" @@ -116,6 +117,24 @@ func EditContainer(oldContainerID string, newConfig types.ContainerJSON) (string return "", err } + // check if new image exists, if not, pull it + _, _, errImage := DockerClient.ImageInspectWithRaw(DockerContext, newConfig.Config.Image) + if errImage != nil { + utils.Log("EditContainer - Image not found, pulling " + newConfig.Config.Image) + out, errPull := DockerClient.ImagePull(DockerContext, newConfig.Config.Image, types.ImagePullOptions{}) + if errPull != nil { + utils.Error("EditContainer - Image not found.", errPull) + return "", errors.New("Image not found. " + errPull.Error()) + } + defer out.Close() + + // wait for image pull to finish + scanner := bufio.NewScanner(out) + for scanner.Scan() { + utils.Log(scanner.Text()) + } + } + // 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 @@ -147,7 +166,7 @@ func EditContainer(oldContainerID string, newConfig types.ContainerJSON) (string } else { utils.Log("EditContainer - Revert started") } - + // recreate container with new informations createResponse, createError := DockerClient.ContainerCreate( DockerContext, diff --git a/src/httpServer.go b/src/httpServer.go index e190353..6e31b5f 100644 --- a/src/httpServer.go +++ b/src/httpServer.go @@ -218,7 +218,9 @@ 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/images/pull-if-missing", docker.PullImageIfMissing) + srapi.HandleFunc("/api/images/pull", docker.PullImage) + srapi.HandleFunc("/api/images", 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}/", docker.GetContainerRoute) srapi.HandleFunc("/api/servapps/{containerId}/network/{networkId}", docker.NetworkContainerRoutes) srapi.HandleFunc("/api/servapps/{containerId}/networks", docker.NetworkContainerRoutes) + srapi.HandleFunc("/api/servapps/{containerId}/check-update", docker.CanUpdateImageRoute) srapi.HandleFunc("/api/servapps", docker.ContainersRoute) srapi.HandleFunc("/api/docker-service", docker.CreateServiceRoute) @@ -250,7 +253,7 @@ func StartServer() { PerUserRequestLimit: 5000, }, )) - srapi.Use(utils.MiddlewareTimeout(30 * time.Second)) + srapi.Use(utils.MiddlewareTimeout(45 * time.Second)) srapi.Use(utils.BlockPostWithoutReferer) srapi.Use(proxy.BotDetectionMiddleware) srapi.Use(httprate.Limit(120, 1*time.Minute,