diff --git a/OpenApi.yml b/OpenApi.yml index 35b7563..21c1f4b 100644 --- a/OpenApi.yml +++ b/OpenApi.yml @@ -255,6 +255,19 @@ paths: $ref: '#/components/schemas/RoomEntry' '500': description: Internal server error + /api/docker-compose.yaml: + get: + tags: + - rooms + summary: Export room as docker-compose + operationId: exportAsDockerCompose + responses: + '200': + description: OK + content: + application/yaml: {} + '500': + description: Internal server error /api/pull: get: diff --git a/client/src/api/api.ts b/client/src/api/api.ts index c5d9626..c830769 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -951,6 +951,36 @@ export class DefaultApi extends BaseAPI { */ export const RoomsApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @summary Export room as docker-compose + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportAsDockerCompose: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/docker-compose.yaml`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @summary Create new room @@ -1342,6 +1372,16 @@ export const RoomsApiAxiosParamCreator = function (configuration?: Configuration export const RoomsApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = RoomsApiAxiosParamCreator(configuration) return { + /** + * + * @summary Export room as docker-compose + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async exportAsDockerCompose(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.exportAsDockerCompose(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @summary Create new room @@ -1474,6 +1514,15 @@ export const RoomsApiFp = function(configuration?: Configuration) { export const RoomsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = RoomsApiFp(configuration) return { + /** + * + * @summary Export room as docker-compose + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + exportAsDockerCompose(options?: any): AxiosPromise { + return localVarFp.exportAsDockerCompose(options).then((request) => request(axios, basePath)); + }, /** * * @summary Create new room @@ -1595,6 +1644,17 @@ export const RoomsApiFactory = function (configuration?: Configuration, basePath * @extends {BaseAPI} */ export class RoomsApi extends BaseAPI { + /** + * + * @summary Export room as docker-compose + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof RoomsApi + */ + public exportAsDockerCompose(options?: AxiosRequestConfig) { + return RoomsApiFp(this.configuration).exportAsDockerCompose(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @summary Create new room diff --git a/internal/api/api.go b/internal/api/api.go index 52e7f59..7e19caf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -59,4 +59,6 @@ func (manager *ApiManagerCtx) Mount(r chi.Router) { r.Post("/restart", manager.roomGenericAction(manager.rooms.Restart)) r.Post("/recreate", manager.roomRecreate) }) + + r.Get("/docker-compose.yaml", manager.dockerCompose) } diff --git a/internal/api/rooms.go b/internal/api/rooms.go index 586d02e..beec9ea 100644 --- a/internal/api/rooms.go +++ b/internal/api/rooms.go @@ -186,3 +186,14 @@ func (manager *ApiManagerCtx) roomGenericAction(action func(id string) error) fu w.WriteHeader(http.StatusNoContent) } } + +func (manager *ApiManagerCtx) dockerCompose(w http.ResponseWriter, r *http.Request) { + response, err := manager.rooms.ExportAsDockerCompose() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + + w.Header().Set("Content-Type", "text/yaml") + w.Write(response) +} diff --git a/internal/room/manager.go b/internal/room/manager.go index b2e9619..9f2910d 100644 --- a/internal/room/manager.go +++ b/internal/room/manager.go @@ -23,6 +23,7 @@ import ( "github.com/docker/go-connections/nat" "github.com/rs/zerolog" "github.com/rs/zerolog/log" + "gopkg.in/yaml.v3" "github.com/m1k1o/neko-rooms/internal/config" "github.com/m1k1o/neko-rooms/internal/policies" @@ -82,6 +83,151 @@ func (manager *RoomManagerCtx) List(labels map[string]string) ([]types.RoomEntry return result, nil } +func (manager *RoomManagerCtx) ExportAsDockerCompose() ([]byte, error) { + services := map[string]any{} + + dockerCompose := map[string]any{ + "version": "3.8", + "networks": map[string]any{ + "default": map[string]any{ + "external": map[string]any{ + "name": manager.config.InstanceNetwork, + }, + }, + }, + "services": services, + } + + containers, err := manager.listContainers(map[string]string{}) + if err != nil { + return nil, err + } + + for _, container := range containers { + containerJson, err := manager.inspectContainer(container.ID) + if err != nil { + return nil, err + } + + labels, err := manager.extractLabels(containerJson.Config.Labels) + if err != nil { + return nil, err + } + + containerName := containerJson.Name + containerName = strings.TrimPrefix(containerName, "/") + + service := map[string]any{} + services[containerName] = service + + service["image"] = labels.NekoImage + service["container_name"] = containerName + service["hostname"] = containerJson.Config.Hostname + service["restart"] = containerJson.HostConfig.RestartPolicy.Name + + // privileged + if containerJson.HostConfig.Privileged { + service["privileged"] = true + } + + // total shm memory usage + service["shm_size"] = containerJson.HostConfig.ShmSize + + // capabilites + capAdd := []string{} + for _, cap := range containerJson.HostConfig.CapAdd { + capAdd = append(capAdd, string(cap)) + } + if len(capAdd) > 0 { + service["cap_add"] = capAdd + } + + // resources + resources := map[string]any{} + { + limits := map[string]string{} + // TODO: CPUShares + if containerJson.HostConfig.NanoCPUs > 0 { + limits["cpus"] = fmt.Sprintf("%f", float64(containerJson.HostConfig.NanoCPUs)/1000000000) + } + if containerJson.HostConfig.Memory > 0 { + limits["memory"] = fmt.Sprintf("%dM", containerJson.HostConfig.Memory/1024/1024) + } + if len(limits) > 0 { + resources["limits"] = limits + } + + deviceRequests := []any{} + for _, device := range containerJson.HostConfig.DeviceRequests { + deviceRequests = append(deviceRequests, map[string]any{ + "driver": device.Driver, + "count": device.Count, + "capabilities": device.Capabilities, + }) + } + if len(deviceRequests) > 0 { + resources["reservations"] = map[string]any{ + "devices": deviceRequests, + } + } + } + if len(resources) > 0 { + service["deploy"] = map[string]any{ + "resources": resources, + } + } + + // ports + ports := []string{} + for port, host := range containerJson.HostConfig.PortBindings { + for _, binding := range host { + ports = append(ports, fmt.Sprintf("%s:%s", binding.HostPort, port)) + } + } + if len(ports) > 0 { + service["ports"] = ports + } + + // environment variables + if len(containerJson.Config.Env) > 0 { + service["environment"] = containerJson.Config.Env + } + + // volumes + volumes := []string{} + for _, mount := range containerJson.Mounts { + if mount.RW { + volumes = append(volumes, fmt.Sprintf("%s:%s:ro", mount.Source, mount.Destination)) + } else { + volumes = append(volumes, fmt.Sprintf("%s:%s", mount.Source, mount.Destination)) + } + } + if len(volumes) > 0 { + service["volumes"] = volumes + } + + // devices + devices := []string{} + for _, device := range containerJson.HostConfig.Devices { + devices = append(devices, fmt.Sprintf("%s:%s:%s", device.PathOnHost, device.PathInContainer, device.CgroupPermissions)) + } + if len(devices) > 0 { + service["devices"] = devices + } + + // labels + labelsArr := []string{} + for key, val := range containerJson.Config.Labels { + labelsArr = append(labelsArr, fmt.Sprintf("%s=%s", key, val)) + } + if len(labelsArr) > 0 { + service["labels"] = labelsArr + } + } + + return yaml.Marshal(dockerCompose) +} + func (manager *RoomManagerCtx) Create(settings types.RoomSettings) (string, error) { if settings.Name != "" && !dockerNames.RestrictedNamePattern.MatchString(settings.Name) { return "", fmt.Errorf("invalid container name, must match %s", dockerNames.RestrictedNameChars) diff --git a/internal/types/room.go b/internal/types/room.go index f9198e6..4443d37 100644 --- a/internal/types/room.go +++ b/internal/types/room.go @@ -283,6 +283,7 @@ type RoomMember struct { type RoomManager interface { Config() RoomsConfig List(labels map[string]string) ([]RoomEntry, error) + ExportAsDockerCompose() ([]byte, error) Create(settings RoomSettings) (string, error) GetEntry(id string) (*RoomEntry, error)