From a92f5e8189d6ab8d5574e5e950c7dae4312363a5 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Mon, 2 Dec 2024 09:38:01 +0000 Subject: [PATCH] feat: implement workspace runtime selection --- .gitignore | 2 + internal/migration/sql/1_initial.up.sql | 1 + internal/template/base_templates.go | 35 ++- internal/workspace/http_handlers.go | 11 + internal/workspace/routes.go | 1 + internal/workspace/workspace.go | 7 + internal/workspace/workspace_manager.go | 30 +++ web/src/templates/build-template-dialog.tsx | 1 + web/src/workspaces/api.ts | 24 +- web/src/workspaces/new-workspace-dialog.tsx | 268 ++++++++++++-------- web/src/workspaces/types.ts | 7 +- 11 files changed, 268 insertions(+), 119 deletions(-) diff --git a/.gitignore b/.gitignore index 58db722..024ed2d 100644 --- a/.gitignore +++ b/.gitignore @@ -214,3 +214,5 @@ $RECYCLE.BIN/ # End of https://www.toptal.com/developers/gitignore/api/macos,windows,linux,go,goland data.sqlite + +dist/ diff --git a/internal/migration/sql/1_initial.up.sql b/internal/migration/sql/1_initial.up.sql index 458fa11..5ae033b 100644 --- a/internal/migration/sql/1_initial.up.sql +++ b/internal/migration/sql/1_initial.up.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS workspaces container_id TEXT NOT NULL, image_tag TEXT NOT NULL, created_at TEXT NOT NULL, + runtime TEXT NOT NULL, CONSTRAINT pk_workspaces PRIMARY KEY (id) ); diff --git a/internal/template/base_templates.go b/internal/template/base_templates.go index 04943c8..e74114e 100644 --- a/internal/template/base_templates.go +++ b/internal/template/base_templates.go @@ -6,7 +6,7 @@ type baseTemplate struct { Content string `json:"-"` } -var baseTemplates = []baseTemplate{fedora40WithSSH} +var baseTemplates = []baseTemplate{fedora40WithSSH, fedora40SSHDocker} var baseTemplateMap = map[string]baseTemplate{ "empty": { @@ -14,7 +14,8 @@ var baseTemplateMap = map[string]baseTemplate{ ID: "empty", Content: "", }, - "fedora-40-openssh": fedora40WithSSH, + "fedora-40-openssh": fedora40WithSSH, + "fedora-40-openssh-docker": fedora40SSHDocker, } var fedora40WithSSH = baseTemplate{ @@ -22,12 +23,36 @@ var fedora40WithSSH = baseTemplate{ ID: "fedora-40-openssh", Content: `FROM fedora:40 +ARG user +ARG password + RUN dnf install -y openssh-server \ && mkdir -p /etc/ssh \ && ssh-keygen -q -N "" -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key \ - && useradd testuser \ - && echo "testuser:12345678" | chpasswd - && usermod -aG wheel testuser + && useradd "$user" \ + && echo "$user:$password" | chpasswd \ + && usermod -aG wheel "$user" + +CMD ["/usr/sbin/sshd", "-D"] +`, +} + +var fedora40SSHDocker = baseTemplate{ + Name: "Fedora 40 + OpenSSH Server + Docker", + ID: "fedora-40-openssh-docker", + Content: `FROM fedora:40 + +ARG user +ARG password + +RUN dnf install -y openssh-server dnf-plugins-core \ + && dnf-3 config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo \ + && dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \ + && mkdir -p /etc/ssh \ + && ssh-keygen -q -N "" -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key \ + && useradd "$user" \ + && echo "$user:$password" | chpasswd \ + && usermod -aG wheel,docker "$user" CMD ["/usr/sbin/sshd", "-D"] `, diff --git a/internal/workspace/http_handlers.go b/internal/workspace/http_handlers.go index cb4874d..887ef43 100644 --- a/internal/workspace/http_handlers.go +++ b/internal/workspace/http_handlers.go @@ -10,6 +10,7 @@ import ( type createWorkspaceRequestBody struct { ImageID string `json:"imageId"` + Runtime string `json:"runtime"` } type updateWorkspaceRequestBody struct { @@ -79,6 +80,7 @@ func createWorkspace(c echo.Context, workspaceName string) error { w, err := mgr.createWorkspace(c.Request().Context(), createWorkspaceOptions{ name: workspaceName, imageID: body.ImageID, + runtime: body.Runtime, }) if err != nil { if errors.Is(err, errImageNotFound) { @@ -164,3 +166,12 @@ func deleteWorkspacePortMapping(c echo.Context) error { return c.NoContent(http.StatusOK) } + +func fetchWorkspaceRuntimes(c echo.Context) error { + mgr := workspaceManagerFrom(c) + runtimes, err := mgr.findAvailableWorkspaceRuntimes(c.Request().Context()) + if err != nil { + return err + } + return c.JSON(http.StatusOK, runtimes) +} diff --git a/internal/workspace/routes.go b/internal/workspace/routes.go index f6480eb..a9a3933 100644 --- a/internal/workspace/routes.go +++ b/internal/workspace/routes.go @@ -11,4 +11,5 @@ func DefineRoutes(g *echo.Group, services service.Services) { g.POST("/workspaces/:workspaceName", updateOrCreateWorkspace, currentWorkspaceMiddleware(true)) g.DELETE("/workspaces/:workspaceName", deleteWorkspace, currentWorkspaceMiddleware(false)) g.DELETE("/workspaces/:workspaceName/forwarded-ports/:portName", deleteWorkspacePortMapping, currentWorkspaceMiddleware(false)) + g.GET("/workspace-runtimes", fetchWorkspaceRuntimes) } diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index a231e9b..971bc7e 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -36,6 +36,8 @@ type workspace struct { Status status `bun:"-" json:"status"` PortMappings []portMapping `bun:"rel:has-many,join:id=workspace_id" json:"ports,omitempty"` + + Runtime string `json:"runtime"` } type portMapping struct { @@ -48,6 +50,11 @@ type portMapping struct { Workspace workspace `bun:"rel:belongs-to,join:workspace_id=id" json:"-"` } +type workspaceRuntime struct { + Name string `json:"name"` + Path string `json:"path"` +} + // status represents the status of a workspace. type status string diff --git a/internal/workspace/workspace_manager.go b/internal/workspace/workspace_manager.go index e490cb2..f8045dd 100644 --- a/internal/workspace/workspace_manager.go +++ b/internal/workspace/workspace_manager.go @@ -31,10 +31,12 @@ type workspaceManager struct { type createWorkspaceOptions struct { name string imageID string + runtime string } var errImageNotFound = errors.New("image not found") var errWorkspaceNotFound = errors.New("workspace not found") +var errRuntimeNotFound = errors.New("runtime not found") func (mgr workspaceManager) findAllWorkspaces(ctx context.Context) ([]workspace, error) { var workspaces []workspace @@ -126,6 +128,16 @@ func (mgr workspaceManager) hasWorkspace(ctx context.Context, name string) (bool } func (mgr workspaceManager) createWorkspace(ctx context.Context, opts createWorkspaceOptions) (*workspace, error) { + info, err := mgr.dockerClient.Info(ctx) + if err != nil { + return nil, err + } + + _, ok := info.Runtimes[opts.runtime] + if !ok { + return nil, errRuntimeNotFound + } + tx, err := mgr.db.BeginTx(ctx, nil) if err != nil { return nil, err @@ -157,6 +169,7 @@ func (mgr workspaceManager) createWorkspace(ctx context.Context, opts createWork {"127.0.0.1", ""}, }, }, + Runtime: opts.runtime, } res, err := mgr.dockerClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, opts.name) @@ -372,3 +385,20 @@ func (mgr workspaceManager) deletePortMapping(ctx context.Context, workspace *wo return nil } + +func (mgr workspaceManager) findAvailableWorkspaceRuntimes(ctx context.Context) ([]workspaceRuntime, error) { + info, err := mgr.dockerClient.Info(ctx) + if err != nil { + return nil, err + } + + runtimes := make([]workspaceRuntime, 0, len(info.Runtimes)) + for name, r := range info.Runtimes { + runtimes = append(runtimes, workspaceRuntime{ + Name: name, + Path: r.Path, + }) + } + + return runtimes, nil +} diff --git a/web/src/templates/build-template-dialog.tsx b/web/src/templates/build-template-dialog.tsx index 430c567..908c96f 100644 --- a/web/src/templates/build-template-dialog.tsx +++ b/web/src/templates/build-template-dialog.tsx @@ -197,6 +197,7 @@ function BuildArgRow({ const finishEditing = useCallback(() => { onFinish({ argName, arg }); + setIsEditing(false); }, [argName, arg, onFinish]); return ( diff --git a/web/src/workspaces/api.ts b/web/src/workspaces/api.ts index 78008e6..1687aae 100644 --- a/web/src/workspaces/api.ts +++ b/web/src/workspaces/api.ts @@ -4,10 +4,17 @@ import { WorkspaceStatus, type Workspace, type WorkspacePortMapping, + type WorkspaceRuntime, } from "./types"; import { useCallback, useState } from "react"; import type { QueryStatus } from "@/lib/query"; +interface CreateWorkspaceConfig { + workspaceName: string; + imageId: string; + runtime: string; +} + function useWorkspaces() { return useSWR( "/workspaces", @@ -24,17 +31,15 @@ function useCreateWorkspace() { async ({ workspaceName, imageId, - }: { - workspaceName: string; - imageId: string; - }): Promise => { + runtime, + }: CreateWorkspaceConfig): Promise => { setStatus({ type: "loading" }); try { const workspace = await mutate( "/workspaces", fetchApi(`/workspaces/${workspaceName}`, { method: "POST", - body: JSON.stringify({ imageId }), + body: JSON.stringify({ imageId, runtime }), headers: { "Content-Type": "application/json", }, @@ -195,10 +200,19 @@ function useAddWorkspacePort() { return { addWorkspacePort, status }; } +function useWorkspaceRuntimes() { + return useSWR( + "/workspace-runtimes", + (): Promise => + fetchApi("/workspace-runtimes").then((res) => res.json()), + ); +} + export { useWorkspaces, useCreateWorkspace, useChangeWorkspaceStatus, useDeleteWorkspace, useAddWorkspacePort, + useWorkspaceRuntimes, }; diff --git a/web/src/workspaces/new-workspace-dialog.tsx b/web/src/workspaces/new-workspace-dialog.tsx index baf9faf..8d06cfe 100644 --- a/web/src/workspaces/new-workspace-dialog.tsx +++ b/web/src/workspaces/new-workspace-dialog.tsx @@ -27,24 +27,101 @@ import { superstructResolver } from "@hookform/resolvers/superstruct"; import { useRef, useEffect } from "react"; import { useForm } from "react-hook-form"; import { nonempty, object, pattern, string, type Infer } from "superstruct"; -import { useCreateWorkspace } from "./api"; +import { useCreateWorkspace, useWorkspaceRuntimes } from "./api"; +import type { TemplateImage } from "@/templates/types"; +import type { WorkspaceRuntime } from "./types"; interface NewWorkspaceDialogProps { onCreateSuccess: () => void; } -const NewWorkspaceForm = object({ +const NewWorkspaceFormSchema = object({ workspaceName: pattern(string(), /^[\w-]+$/), imageId: nonempty(string()), + runtime: nonempty(string()), }); function NewWorkspaceDialog({ onCreateSuccess }: NewWorkspaceDialogProps) { - const { data: templateImages, isLoading, error } = useTemplateImages(); + const { + data: templateImages, + isLoading: isLoadingImages, + error, + } = useTemplateImages(); + const { data: runtimes, isLoading: isLoadingRuntimes } = + useWorkspaceRuntimes(); + const isLoading = isLoadingImages || isLoadingRuntimes; + + function content() { + if (error) { + console.log(error); + return ( +

+ An error occurred when fetching available options. +

+ ); + } + if (isLoading) { + return ( +
+ +
+ ); + } + if (!templateImages || !runtimes) { + return null; + } + if (templateImages.length === 0) { + return ( + <> +

+ No images found. Create and build a template, and the resulting + image will show up here. +

+ + What are images? + + An image is used to bootstrap a workspace, including the operating + system, the environment, and packages, as specified by a template. + + + + ); + } + + return ( + + ); + } + + return ( + + + New workspace + + {content()} + + ); +} + +function NewWorkspaceForm({ + templateImages, + runtimes, + onCreateSuccess, +}: { + templateImages: TemplateImage[]; + runtimes: WorkspaceRuntime[]; + onCreateSuccess: () => void; +}) { const form = useForm({ - resolver: superstructResolver(NewWorkspaceForm), + resolver: superstructResolver(NewWorkspaceFormSchema), defaultValues: { workspaceName: "", imageId: "", + runtime: "", }, }); const { createWorkspace, status } = useCreateWorkspace(); @@ -77,117 +154,92 @@ function NewWorkspaceDialog({ onCreateSuccess }: NewWorkspaceDialogProps) { } }, [status.type, toast, onCreateSuccess]); - async function onSubmit(values: Infer) { + async function onSubmit(values: Infer) { await createWorkspace({ workspaceName: values.workspaceName, imageId: values.imageId, + runtime: values.runtime, }); } - function content() { - if (error) { - console.log(error); - return ( -

- An error occurred when fetching available options. -

- ); - } - if (isLoading) { - return ( -
- -
- ); - } - if (!templateImages) { - return null; - } - if (templateImages.length === 0) { - return ( - <> -

- No images found. Create and build a template, and the resulting - image will show up here. -

- - What are images? - - An image is used to bootstrap a workspace, including the operating - system, the environment, and packages, as specified by a template. - - - - ); - } - - return ( -
- - ( - - Workspace name - - - - - Must only contain alphanumeric characters and "-". - - - - )} - /> - - ( - - Image for this workspace - - - - )} - /> - - - - - - - ); - } - return ( - - - New workspace - - {content()} - +
+ + ( + + Workspace name + + + + + Must only contain alphanumeric characters and "-". + + + + )} + /> + + ( + + Image for this workspace + + + + )} + /> + + ( + + Docker runtime + + + )} + /> + + + + + + ); } diff --git a/web/src/workspaces/types.ts b/web/src/workspaces/types.ts index a5309c6..7d8fc01 100644 --- a/web/src/workspaces/types.ts +++ b/web/src/workspaces/types.ts @@ -20,5 +20,10 @@ interface Workspace { ports?: WorkspacePortMapping[]; } +interface WorkspaceRuntime { + name: string; + path: string; +} + export { WorkspaceStatus }; -export type { Workspace, WorkspacePortMapping }; +export type { Workspace, WorkspaceRuntime, WorkspacePortMapping };