diff --git a/OpenApi.yml b/OpenApi.yml index 7988b6a..ca3499d 100644 --- a/OpenApi.yml +++ b/OpenApi.yml @@ -221,6 +221,51 @@ paths: '500': description: Internal server error + /api/pull: + get: + tags: + - default + summary: Get pull status + operationId: pullStatus + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PullStatus' + '500': + description: Internal server error + post: + tags: + - default + summary: Start new pull + operationId: pullStart + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PullStart' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PullStatus' + '500': + description: Internal server error + delete: + tags: + - default + summary: Stop existing pull in progress + operationId: pullStop + responses: + '204': + description: OK + '500': + description: Internal server error + components: schemas: RoomsConfig: @@ -462,3 +507,55 @@ components: url: type: string example: https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi + + PullStart: + type: object + properties: + neko_image: + type: string + example: m1k1o/neko:latest + registry_user: + type: string + registry_pass: + type: string + + PullLayer: + type: object + properties: + status: + type: string + example: m1k1o/neko:latest + progressDetail: + type: object + properties: + current: + type: number + total: + type: number + progress: + type: string + id: + type: string + + PullStatus: + type: object + properties: + active: + type: boolean + example: true + started: + type: string + format: datetime + example: "2021-03-07T21:56:34Z" + layers: + type: object + additionalProperties: + $ref: '#/components/schemas/PullLayer' + status: + type: array + items: + type: string + finished: + type: string + format: datetime + example: "2021-03-07T21:56:34Z" diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 48d8591..84a4061 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -100,6 +100,118 @@ export interface BrowserPolicyExtension { */ 'url'?: string; } +/** + * + * @export + * @interface PullLayer + */ +export interface PullLayer { + /** + * + * @type {string} + * @memberof PullLayer + */ + 'status'?: string; + /** + * + * @type {PullLayerProgressDetail} + * @memberof PullLayer + */ + 'progressDetail'?: PullLayerProgressDetail; + /** + * + * @type {string} + * @memberof PullLayer + */ + 'progress'?: string; + /** + * + * @type {string} + * @memberof PullLayer + */ + 'id'?: string; +} +/** + * + * @export + * @interface PullLayerProgressDetail + */ +export interface PullLayerProgressDetail { + /** + * + * @type {number} + * @memberof PullLayerProgressDetail + */ + 'current'?: number; + /** + * + * @type {number} + * @memberof PullLayerProgressDetail + */ + 'total'?: number; +} +/** + * + * @export + * @interface PullStart + */ +export interface PullStart { + /** + * + * @type {string} + * @memberof PullStart + */ + 'neko_image'?: string; + /** + * + * @type {string} + * @memberof PullStart + */ + 'registry_user'?: string; + /** + * + * @type {string} + * @memberof PullStart + */ + 'registry_pass'?: string; +} +/** + * + * @export + * @interface PullStatus + */ +export interface PullStatus { + /** + * + * @type {boolean} + * @memberof PullStatus + */ + 'active'?: boolean; + /** + * + * @type {string} + * @memberof PullStatus + */ + 'started'?: string; + /** + * + * @type {{ [key: string]: PullLayer; }} + * @memberof PullStatus + */ + 'layers'?: { [key: string]: PullLayer; }; + /** + * + * @type {Array} + * @memberof PullStatus + */ + 'status'?: Array; + /** + * + * @type {string} + * @memberof PullStatus + */ + 'finished'?: string; +} /** * * @export @@ -577,6 +689,231 @@ export class ConfigApi extends BaseAPI { } +/** + * DefaultApi - axios parameter creator + * @export + */ +export const DefaultApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary Start new pull + * @param {PullStart} [pullStart] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + pullStart: async (pullStart?: PullStart, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/pull`; + // 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: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(pullStart, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Get pull status + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + pullStatus: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/pull`; + // 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 Stop existing pull in progress + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + pullStop: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/pull`; + // 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: 'DELETE', ...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, + }; + }, + } +}; + +/** + * DefaultApi - functional programming interface + * @export + */ +export const DefaultApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = DefaultApiAxiosParamCreator(configuration) + return { + /** + * + * @summary Start new pull + * @param {PullStart} [pullStart] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async pullStart(pullStart?: PullStart, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.pullStart(pullStart, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @summary Get pull status + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async pullStatus(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.pullStatus(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @summary Stop existing pull in progress + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async pullStop(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.pullStop(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * DefaultApi - factory interface + * @export + */ +export const DefaultApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = DefaultApiFp(configuration) + return { + /** + * + * @summary Start new pull + * @param {PullStart} [pullStart] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + pullStart(pullStart?: PullStart, options?: any): AxiosPromise { + return localVarFp.pullStart(pullStart, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Get pull status + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + pullStatus(options?: any): AxiosPromise { + return localVarFp.pullStatus(options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Stop existing pull in progress + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + pullStop(options?: any): AxiosPromise { + return localVarFp.pullStop(options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * DefaultApi - object-oriented interface + * @export + * @class DefaultApi + * @extends {BaseAPI} + */ +export class DefaultApi extends BaseAPI { + /** + * + * @summary Start new pull + * @param {PullStart} [pullStart] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public pullStart(pullStart?: PullStart, options?: AxiosRequestConfig) { + return DefaultApiFp(this.configuration).pullStart(pullStart, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Get pull status + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public pullStatus(options?: AxiosRequestConfig) { + return DefaultApiFp(this.configuration).pullStatus(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Stop existing pull in progress + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public pullStop(options?: AxiosRequestConfig) { + return DefaultApiFp(this.configuration).pullStop(options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * RoomsApi - axios parameter creator * @export diff --git a/client/src/components/Pull.vue b/client/src/components/Pull.vue new file mode 100644 index 0000000..e528a64 --- /dev/null +++ b/client/src/components/Pull.vue @@ -0,0 +1,142 @@ + + + diff --git a/client/src/store/index.ts b/client/src/store/index.ts index 08fc5b0..8537505 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -9,6 +9,8 @@ import { RoomSettings, RoomStats, RoomsApi, + DefaultApi, + PullStatus, } from '@/api/index' import { state, State } from './state' @@ -21,6 +23,7 @@ const configuration = new Configuration({ const configApi = new ConfigApi(configuration) const roomsApi = new RoomsApi(configuration) +const defaultApi = new DefaultApi(configuration) export default new Vuex.Store({ state, @@ -55,6 +58,9 @@ export default new Vuex.Store({ const roomEntries = state.rooms.filter(({ id }) => id != roomId) Vue.set(state, 'rooms', roomEntries) }, + PULL_STATUS(state: State, pullStatus: PullStatus) { + Vue.set(state, 'pullStatus', pullStatus) + }, }, actions: { async ROOMS_CONFIG({ commit }: ActionContext) { @@ -112,6 +118,23 @@ export default new Vuex.Store({ commit('ROOMS_PUT', res.data) return res.data }, + + async PULL_START({ commit }: ActionContext, nekoImage: string) { + const res = await defaultApi.pullStart({ + // eslint-disable-next-line + neko_image: nekoImage, + }) + commit('PULL_STATUS', res.data) + return res.data + }, + async PULL_STATUS({ commit }: ActionContext) { + const res = await defaultApi.pullStatus() + commit('PULL_STATUS', res.data) + }, + async PULL_STOP() { + const res = await defaultApi.pullStop() + return res.data + }, }, modules: { } diff --git a/client/src/store/state.ts b/client/src/store/state.ts index 89647f3..ccdd67a 100644 --- a/client/src/store/state.ts +++ b/client/src/store/state.ts @@ -5,6 +5,7 @@ import { BrowserPolicyContent, BrowserPolicyTypeEnum, BrowserPolicyExtension, + PullStatus, } from '@/api/index' export interface BrowserPolicyConfig { @@ -208,6 +209,13 @@ export const state = { // eslint-disable-next-line persistent_data: false, } as BrowserPolicyContent, + + // + // default + // + pullStatus: { + active: false, + } as PullStatus } export type State = typeof state diff --git a/client/src/views/Home.vue b/client/src/views/Home.vue index 5edfba1..7aa1463 100644 --- a/client/src/views/Home.vue +++ b/client/src/views/Home.vue @@ -58,6 +58,10 @@ + +
+ +
@@ -66,6 +70,7 @@ import { Component, Vue, Watch } from 'vue-property-decorator' import RoomsList from '@/components/RoomsList.vue' import RoomsQuick from '@/components/RoomsQuick.vue' import RoomsCreate from '@/components/RoomsCreate.vue' +import Pull from '@/components/Pull.vue' import { RoomEntry } from '@/api/index' @Component({ @@ -73,6 +78,7 @@ import { RoomEntry } from '@/api/index' RoomsList, RoomsQuick, RoomsCreate, + Pull, } }) export default class Home extends Vue { diff --git a/internal/api/manager.go b/internal/api/manager.go index 639fd54..b3a4340 100644 --- a/internal/api/manager.go +++ b/internal/api/manager.go @@ -41,4 +41,10 @@ func (manager *ApiManagerCtx) Mount(r chi.Router) { r.Post("/restart", manager.roomGenericAction(manager.rooms.Restart)) r.Post("/recreate", manager.roomRecreate) }) + + r.Route("/pull", func(r chi.Router) { + r.Get("/", manager.roomPullStatus) + r.Post("/", manager.roomPullStart) + r.Delete("/", manager.roomPullStop) + }) } diff --git a/internal/api/rooms.go b/internal/api/rooms.go index 67110a1..162870f 100644 --- a/internal/api/rooms.go +++ b/internal/api/rooms.go @@ -169,3 +169,38 @@ func (manager *ApiManagerCtx) roomGenericAction(action func(id string) error) fu w.WriteHeader(http.StatusNoContent) } } + +func (manager *ApiManagerCtx) roomPullStart(w http.ResponseWriter, r *http.Request) { + request := types.PullStart{} + + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), 400) + return + } + + err := manager.rooms.PullStart(request) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + + response := manager.rooms.PullStatus() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (manager *ApiManagerCtx) roomPullStatus(w http.ResponseWriter, r *http.Request) { + response := manager.rooms.PullStatus() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (manager *ApiManagerCtx) roomPullStop(w http.ResponseWriter, r *http.Request) { + err := manager.rooms.PullStop() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/room/manager.go b/internal/room/manager.go index fda64e8..68e840b 100644 --- a/internal/room/manager.go +++ b/internal/room/manager.go @@ -55,6 +55,7 @@ type RoomManagerCtx struct { logger zerolog.Logger config *config.Room client *dockerClient.Client + pull PullConfig } func (manager *RoomManagerCtx) Config() types.RoomsConfig { diff --git a/internal/room/pull.go b/internal/room/pull.go new file mode 100644 index 0000000..f1a4fd1 --- /dev/null +++ b/internal/room/pull.go @@ -0,0 +1,156 @@ +package room + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "m1k1o/neko_rooms/internal/types" + "m1k1o/neko_rooms/internal/utils" + "sync" + "time" + + dockerTypes "github.com/docker/docker/api/types" +) + +type PullConfig struct { + mu sync.Mutex + cancel func() + status types.PullStatus +} + +func (pull *PullConfig) TryInitialize(cancel func()) bool { + pull.mu.Lock() + defer pull.mu.Unlock() + + if pull.status.Active { + cancel() + return false + } + + now := time.Now() + pull.cancel = cancel + + pull.status = types.PullStatus{ + Active: true, + Started: &now, + Layers: map[string]types.PullLayer{}, + Status: []string{}, + } + + return true +} + +func (pull *PullConfig) SetDone() { + pull.mu.Lock() + defer pull.mu.Unlock() + + now := time.Now() + pull.status.Active = false + pull.status.Finished = &now +} + +func (pull *PullConfig) Stop() bool { + pull.mu.Lock() + defer pull.mu.Unlock() + + if pull.status.Active { + pull.cancel() + } + + return pull.status.Active +} + +func (pull *PullConfig) Get() types.PullStatus { + pull.mu.Lock() + defer pull.mu.Unlock() + + return pull.status +} + +func (manager *RoomManagerCtx) PullStart(request types.PullStart) error { + if in, _ := utils.ArrayIn(request.NekoImage, manager.config.NekoImages); !in { + return fmt.Errorf("unknown neko image") + } + + ctx, cancel := context.WithCancel(context.Background()) + if !manager.pull.TryInitialize(cancel) { + return fmt.Errorf("pull is already in progess") + } + + // handle registry auth + var opts dockerTypes.ImagePullOptions + if request.RegistryUser != "" && request.RegistryPass != "" { + authConfig := dockerTypes.AuthConfig{ + Username: request.RegistryUser, + Password: request.RegistryPass, + } + + encodedJSON, err := json.Marshal(authConfig) + if err != nil { + panic(err) + } + + opts = dockerTypes.ImagePullOptions{ + RegistryAuth: base64.URLEncoding.EncodeToString(encodedJSON), + } + } + + reader, err := manager.client.ImagePull(ctx, request.NekoImage, opts) + + if err != nil { + manager.pull.SetDone() + return err + } + + go func() { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + data := scanner.Bytes() + + layer := types.PullLayer{} + if err := json.Unmarshal(data, &layer); err != nil { + manager.pull.status.Status = append( + manager.pull.status.Status, + fmt.Sprintf("Error while parsing pull response: %s", err), + ) + + continue + } + + if layer.ProgressDetail != nil { + manager.pull.status.Layers[layer.ID] = layer + } else { + manager.pull.status.Status = append( + manager.pull.status.Status, + layer.Status, + ) + } + } + + if err := scanner.Err(); err != nil { + manager.pull.status.Status = append( + manager.pull.status.Status, + fmt.Sprintf("Error while reading pull response: %s", err), + ) + } + + reader.Close() + manager.pull.SetDone() + }() + + return nil +} + +func (manager *RoomManagerCtx) PullStop() error { + if !manager.pull.Stop() { + return fmt.Errorf("pull is not in progess") + } + + return nil +} + +func (manager *RoomManagerCtx) PullStatus() types.PullStatus { + return manager.pull.Get() +} diff --git a/internal/types/room.go b/internal/types/room.go index 5078f81..89639df 100644 --- a/internal/types/room.go +++ b/internal/types/room.go @@ -245,6 +245,30 @@ type RoomMember struct { Muted bool `json:"muted"` } +type PullStart struct { + NekoImage string `json:"neko_image"` + RegistryUser string `json:"registry_user"` + RegistryPass string `json:"registry_pass"` +} + +type PullLayer struct { + Status string `json:"status"` + ProgressDetail *struct { + Current int `json:"current"` + Total int `json:"total"` + } `json:"progressDetail"` + Progress string `json:"progress"` + ID string `json:"id"` +} + +type PullStatus struct { + Active bool `json:"active"` + Started *time.Time `json:"started"` + Layers map[string]PullLayer `json:"layers"` + Status []string `json:"status"` + Finished *time.Time `json:"finished"` +} + type RoomManager interface { Config() RoomsConfig List() ([]RoomEntry, error) @@ -259,4 +283,8 @@ type RoomManager interface { Start(id string) error Stop(id string) error Restart(id string) error + + PullStart(request PullStart) error + PullStop() error + PullStatus() PullStatus }