pull images.

This commit is contained in:
Miroslav Šedivý
2021-12-11 22:09:21 +01:00
parent 30807ae903
commit 64a3a8898b
11 changed files with 839 additions and 0 deletions
+97
View File
@@ -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"
+337
View File
@@ -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<string>}
* @memberof PullStatus
*/
'status'?: Array<string>;
/**
*
* @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<RequestArgs> => {
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<RequestArgs> => {
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<RequestArgs> => {
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<PullStatus>> {
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<PullStatus>> {
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<void>> {
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<PullStatus> {
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<PullStatus> {
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<void> {
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
+142
View File
@@ -0,0 +1,142 @@
<template>
<span>
<v-btn
@click="dialog = true"
:loading="loading"
color="info"
dark
>
<v-icon class="mr-2" color="white">mdi-cloud-download-outline</v-icon> Pull neko images
</v-btn>
<v-dialog v-model="dialog" max-width="920px">
<v-card>
<v-card-title class="headline">
Pull neko images
</v-card-title>
<v-card-text>
<template v-if="status.layers.length > 0">
<pre v-for="layer in status.layers" :key="layer.id">{{ layer.id }} {{ layer.progress && layer.progress + ' ' }}{{ layer.status }}</pre>
<br />
</template>
<template v-if="status.status.length > 0">
<pre v-for="text in status.status" :key="text">{{ text }}</pre>
</template>
<pre v-else>Preparing docker image pull</pre>
</v-card-text>
<v-card-actions>
<template v-if="!status.active">
<v-select
v-model="nekoImage"
:items="nekoImages"
dense
outlined
hide-details
label="Neko image"
></v-select>
<v-btn color="green" class="ml-2" :loading="loading" @click="Start">
Start
</v-btn>
</template>
<v-spacer></v-spacer>
<v-btn v-if="status.active" color="red" text :loading="loading" @click="Stop">
Stop
</v-btn>
<v-btn v-else color="grey" text @click="dialog = false">
Close
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</span>
</template>
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'
@Component({
components: {}
})
export default class PUll extends Vue {
private dialog = false
private loading = false
private nekoImage = ''
get status() {
return this.$store.state.pullStatus
}
get nekoImages() {
return this.$store.state.roomsConfig.neko_images
}
async Start() {
this.loading = true
try {
await this.$store.dispatch('PULL_START', this.nekoImage)
} catch(e) {
if (e.response) {
this.$swal({
title: 'Server error',
text: e.response.data,
icon: 'error',
})
} else {
this.$swal({
title: 'Network error',
text: String(e),
icon: 'error',
})
}
} finally {
this.loading = false
}
}
async Stop() {
this.loading = true
try {
await this.$store.dispatch('PULL_STOP')
} catch(e) {
if (e.response) {
this.$swal({
title: 'Server error',
text: e.response.data,
icon: 'error',
})
} else {
this.$swal({
title: 'Network error',
text: String(e),
icon: 'error',
})
}
} finally {
this.loading = false
this.dialog = false
}
}
private interval!: number
async mounted() {
await this.$store.dispatch('PULL_STATUS')
this.interval = window.setInterval(async () => {
if (!this.status.active) return
try {
await this.$store.dispatch('PULL_STATUS')
} catch(e) {
console.error(e)
}
}, 1000)
}
beforeDestroy() {
window.clearInterval(this.interval)
}
}
</script>
+23
View File
@@ -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<State, State>) {
@@ -112,6 +118,23 @@ export default new Vuex.Store({
commit('ROOMS_PUT', res.data)
return res.data
},
async PULL_START({ commit }: ActionContext<State, State>, 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<State, State>) {
const res = await defaultApi.pullStatus()
commit('PULL_STATUS', res.data)
},
async PULL_STOP() {
const res = await defaultApi.pullStop()
return res.data
},
},
modules: {
}
+8
View File
@@ -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
+6
View File
@@ -58,6 +58,10 @@
</v-row>
<RoomsList :loading="loading" />
<div class="mt-5 text-center">
<Pull />
</div>
</v-container>
</template>
@@ -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 {
+6
View File
@@ -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)
})
}
+35
View File
@@ -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)
}
+1
View File
@@ -55,6 +55,7 @@ type RoomManagerCtx struct {
logger zerolog.Logger
config *config.Room
client *dockerClient.Client
pull PullConfig
}
func (manager *RoomManagerCtx) Config() types.RoomsConfig {
+156
View File
@@ -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()
}
+28
View File
@@ -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
}