admin: Add purge interface

This commit adds a new option to the admin interface to purge an
instance. This requires the user to manually confirm the name of the
instance.
This commit is contained in:
Tom Wiesing 2023-01-16 18:22:13 +01:00
parent 3321b5d0ba
commit 2384ee0841
No known key found for this signature in database
16 changed files with 175 additions and 84 deletions

View file

@ -3,8 +3,6 @@ package cmd
import (
wisski_distillery "github.com/FAU-CDI/wisski-distillery"
"github.com/FAU-CDI/wisski-distillery/internal/cli"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
"github.com/FAU-CDI/wisski-distillery/pkg/logging"
"github.com/tkw1536/goprogram/exit"
)
@ -28,21 +26,11 @@ func (purge) Description() wisski_distillery.Description {
}
}
var errPurgeNoDetails = exit.Error{
Message: "unable to find instance details for purge: %s",
ExitCode: exit.ExitGeneric,
}
var errPurgeNoConfirmation = exit.Error{
Message: "aborting after request was not confirmed. either type `yes` or pass `--yes` on the command line",
ExitCode: exit.ExitGeneric,
}
var errPurgeGeneric = exit.Error{
Message: "unable to purge instance %q: %s",
ExitCode: exit.ExitGeneric,
}
func (p purge) Run(context wisski_distillery.Context) error {
dis := context.Environment
slug := p.Positionals.Slug
@ -57,57 +45,5 @@ func (p purge) Run(context wisski_distillery.Context) error {
}
}
// load the instance (first via bookkeeping, then via defaults)
logging.LogMessage(context.Stderr, context.Context, "Checking bookkeeping table")
instance, err := dis.Instances().WissKI(context.Context, slug)
if err == instances.ErrWissKINotFound {
context.Println("Not found in bookkeeping table, assuming defaults")
instance, err = dis.Instances().Create(slug)
}
if err != nil {
return errPurgeNoDetails.WithMessageF(err)
}
// remove docker stack
logging.LogMessage(context.Stderr, context.Context, "Stopping and removing docker container")
if err := instance.Barrel().Stack().Down(context.Context, context.Stderr); err != nil {
context.EPrintln(err)
}
// remove the filesystem
logging.LogMessage(context.Stderr, context.Context, "Removing from filesystem %s", instance.FilesystemBase)
if err := dis.Still.Environment.RemoveAll(instance.FilesystemBase); err != nil {
context.EPrintln(err)
}
// purge all the instance specific resources
if err := logging.LogOperation(func() error {
domain := instance.Domain()
for _, pc := range dis.Provisionable() {
logging.LogMessage(context.Stderr, context.Context, "Purging %s resources", pc.Name())
err := pc.Purge(context.Context, instance.Instance, domain)
if err != nil {
return err
}
}
return nil
}, context.Stderr, context.Context, "Purging instance-specific resources"); err != nil {
return errPurgeGeneric.WithMessageF(slug, err)
}
// remove from bookkeeping
logging.LogMessage(context.Stderr, context.Context, "Removing instance from bookkeeping")
if err := instance.Bookkeeping().Delete(context.Context); err != nil {
context.EPrintln(err)
}
// remove the filesystem
logging.LogMessage(context.Stderr, context.Context, "Remove lock data")
if instance.Locker().TryUnlock(context.Context) {
context.EPrintln("instance was not locked")
}
logging.LogMessage(context.Stderr, context.Context, "Instance %s has been purged", slug)
return nil
return dis.Purger().Purge(context.Context, context.Stdout, slug)
}

View file

@ -1,7 +1,7 @@
package cli
// ===========================================================================================================
// This file was generated automatically at 15-01-2023 11:33:49 using gogenlicense.
// This file was generated automatically at 16-01-2023 17:01:43 using gogenlicense.
// Do not edit manually, as changes may be overwritten.
// ===========================================================================================================
@ -2417,7 +2417,7 @@ package cli
// # Generation
//
// This variable and the associated documentation have been automatically generated using the 'gogenlicense' tool.
// It was last updated at 15-01-2023 11:33:49.
// It was last updated at 16-01-2023 17:01:43.
var LegalNotices string
func init() {

View file

@ -10,6 +10,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static/custom"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter/logger"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances/purger"
"github.com/julienschmidt/httprouter"
"github.com/rs/zerolog"
@ -32,6 +33,8 @@ type Admin struct {
Policy *policy.Policy
Custom *custom.Custom
Purger *purger.Purger
}
Analytics *lazy.PoolAnalytics

View file

@ -40,10 +40,10 @@
<td>
<code>{{ .Info.Running }}</code>
<div class="pure-button-group" role="group">
<button class="remote-action pure-button pure-button-action" data-action="start" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload="true">
<button class="remote-action pure-button pure-button-action" data-action="start" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload>
(Re)Start
</button>
<button class="remote-action pure-button pure-button-danger" data-action="stop" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload="true">
<button class="remote-action pure-button pure-button-danger" data-action="stop" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload>
Stop
</button>
</div>
@ -144,7 +144,7 @@
<tr>
<td>
Last Rebuild <br>
<button class="remote-action pure-button pure-button-action" data-action="rebuild" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload="true">Rebuild</button>
<button class="remote-action pure-button pure-button-action" data-action="rebuild" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload>Rebuild</button>
</td>
<td>
<code class="date">{{ .Info.LastRebuild.Format "2006-01-02T15:04:05Z07:00" }}</code>
@ -153,7 +153,7 @@
<tr>
<td>
Last Cron<br>
<button class="remote-action pure-button pure-button-action" data-action="cron" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload="true">Cron</button>
<button class="remote-action pure-button pure-button-action" data-action="cron" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload>Cron</button>
</td>
<td>
<code class="date">{{ .Info.LastRebuild.Format "2006-01-02T15:04:05Z07:00" }}</code>
@ -162,7 +162,7 @@
<tr>
<td>
Last Update <br>
<button class="remote-action pure-button pure-button-action" data-action="update" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload="true">Update</button>
<button class="remote-action pure-button pure-button-action" data-action="update" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload>Update</button>
</td>
<td>
<code class="date">{{ .Info.LastUpdate.Format "2006-01-02T15:04:05Z07:00" }}</code><br>
@ -437,7 +437,7 @@
<div class="pure-u-1-1">
<h2 id="snapshots">Snapshots</h2>
<p>
<button class="remote-action pure-button pure-button-action" data-action="snapshot" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload="true">Take a snapshot</button>
<button class="remote-action pure-button pure-button-action" data-action="snapshot" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload>Take a snapshot</button>
</p>
</div>
@ -467,4 +467,22 @@
</tbody>
</table>
</div>
<div class="pure-u-1-1">
<h2 id="overview">Dangerous Actions</h2>
</div>
<div class="pure-u-1 pure-u-xl-2-5">
<p>
Purging this instance completely removes it from the distillery.
Backups containing the instance will remain, but it will not be possible to restore it directly.
You must enter the slug <code>{{ .Instance.Slug }}</code> to confirm purging.
</p>
<form class="pure-form">
<fieldset>
<input type="text" id="purge-confirm-slug" placeholder="{{ .Instance.Slug }}" />
<button class="remote-action pure-button pure-button-danger" data-action="purge" data-param="{{ .Instance.Slug }}" data-confirm-param="#purge-confirm-slug" data-buffer="1000" data-force-reload="/admin/">Purge Instance</button>
</fieldset>
</form>
</div>
{{ end }}

View file

@ -22,8 +22,8 @@ type InstanceAction struct {
var socketInstanceActions = map[string]InstanceAction{
"snapshot": {
HandleInteractive: func(ctx context.Context, info *Admin, instance *wisski.WissKI, out io.Writer, params ...string) error {
return info.Dependencies.Exporter.MakeExport(
HandleInteractive: func(ctx context.Context, admin *Admin, instance *wisski.WissKI, out io.Writer, params ...string) error {
return admin.Dependencies.Exporter.MakeExport(
ctx,
out,
exporter.ExportTask{
@ -60,6 +60,11 @@ var socketInstanceActions = map[string]InstanceAction{
return instance.Barrel().Stack().Down(ctx, out)
},
},
"purge": {
HandleInteractive: func(ctx context.Context, admin *Admin, instance *wisski.WissKI, out io.Writer, params ...string) error {
return admin.Dependencies.Purger.Purge(ctx, out, instance.Slug)
},
},
}
func (admin *Admin) serveSocket(conn httpx.WebSocketConnection) {

View file

@ -0,0 +1,6 @@
---
title: Removing instances from admin interface
date: 2023-01-16
---
- added an option to purge and remove instances from the admin page

View file

@ -21,6 +21,6 @@ var AssetsUser = Assets{
// AssetsAdmin contains assets for the 'Admin' entrypoint.
var AssetsAdmin = Assets{
Scripts: `<script nomodule="" defer src="/static/User.b2f9a57c.js"></script><script type="module" src="/static/User.e0367d79.js"></script><script type="module" src="/static/Default.38d394c2.js"></script><script src="/static/Default.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/Admin.4ca3cb6f.js"></script><script src="/static/Admin.9750ba9c.js" nomodule="" defer></script>`,
Scripts: `<script nomodule="" defer src="/static/User.b2f9a57c.js"></script><script type="module" src="/static/User.e0367d79.js"></script><script type="module" src="/static/Default.38d394c2.js"></script><script src="/static/Default.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/Admin.1a380f6f.js"></script><script src="/static/Admin.cb58d290.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/Default.db26a303.css"><link rel="stylesheet" href="/static/Admin.6d59e220.css"><link rel="stylesheet" href="/static/User.840de3b4.css"><link rel="stylesheet" href="/static/User.68febbf8.css"><link rel="stylesheet" href="/static/Admin.6d2ae968.css">`,
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -49,16 +49,40 @@ function makeTextBuffer(target: HTMLElement, scrollContainer: HTMLElement, size:
const remote_action = document.getElementsByClassName('remote-action')
Array.from(remote_action).forEach((element) => {
const action = element.getAttribute('data-action') as string;
const reload = element.hasAttribute('data-force-reload');
const reload = element.getAttribute('data-force-reload');
const param = element.getAttribute('data-param') as string | undefined;
const confirmElementName = element.getAttribute('data-confirm-param');
const confirmElement = (confirmElementName ? document.querySelector(confirmElementName) : null) as HTMLInputElement | null;
const bufferSize = (function () {
const number = parseInt(element.getAttribute('data-buffer') ?? "", 10) ?? 0;
return (isFinite(number) && number > 0) ? number : 0;
})()
const validate = function() {
if (!confirmElement) return true
return confirmElement.value === param;
}
if (confirmElement) {
const runValidation = () => {
if (validate()) {
element.removeAttribute('disabled')
} else {
element.setAttribute('disabled', 'disabled')
}
}
confirmElement.addEventListener('change', runValidation)
runValidation()
}
element.addEventListener('click', function (ev) {
ev.preventDefault();
// do nothing if the validation fails
if (!validate()) return;
// create a modal dialog and append it to the body
const modal = document.createElement("div")
modal.className = "modal-terminal"
@ -73,14 +97,18 @@ Array.from(remote_action).forEach((element) => {
// create a button to eventually close everything
const button = document.createElement("button")
button.className = "pure-button pure-button-success"
button.append(reload ? "Close & Reload" : "Close")
button.append(typeof reload === 'string' ? "Close & Reload" : "Close")
button.addEventListener('click', function (event) {
event.preventDefault();
if (reload) {
button.setAttribute('disabled', 'disabled');
if (typeof reload === 'string') {
button.setAttribute('disabled', 'disabled')
target.innerHTML = 'Reloading page ...'
location.reload()
if (reload === '') {
location.reload()
} else {
location.href = reload
}
return;
}

View file

@ -0,0 +1,86 @@
package purger
import (
"context"
"fmt"
"io"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
"github.com/FAU-CDI/wisski-distillery/pkg/logging"
"github.com/tkw1536/goprogram/exit"
)
// Purger purges instances from the distillery
type Purger struct {
component.Base
Dependencies struct {
Instances *instances.Instances
Provisionable []component.Provisionable
}
}
var errPurgeNoDetails = exit.Error{
Message: "unable to find instance details for purge: %s",
ExitCode: exit.ExitGeneric,
}
var errPurgeGeneric = exit.Error{
Message: "unable to purge instance %q: %s",
ExitCode: exit.ExitGeneric,
}
// Purge permanently purges an instance from the distillery.
// The instance does not have to exist; in which case the resources are also deleted.
func (purger *Purger) Purge(ctx context.Context, out io.Writer, slug string) error {
logging.LogMessage(out, ctx, "Checking bookkeeping table")
instance, err := purger.Dependencies.Instances.WissKI(ctx, slug)
if err == instances.ErrWissKINotFound {
fmt.Fprintln(out, "Not found in bookkeeping table, assuming defaults")
instance, err = purger.Dependencies.Instances.Create(slug)
}
if err != nil {
return errPurgeNoDetails.WithMessageF(err)
}
// remove docker stack
logging.LogMessage(out, ctx, "Stopping and removing docker container")
if err := instance.Barrel().Stack().Down(ctx, out); err != nil {
fmt.Fprintln(out, err)
}
// remove the filesystem
logging.LogMessage(out, ctx, "Removing from filesystem %s", instance.FilesystemBase)
if err := purger.Environment.RemoveAll(instance.FilesystemBase); err != nil {
fmt.Fprintln(out, err)
}
// purge all the instance specific resources
if err := logging.LogOperation(func() error {
domain := instance.Domain()
for _, pc := range purger.Dependencies.Provisionable {
logging.LogMessage(out, ctx, "Purging %s resources", pc.Name())
err := pc.Purge(ctx, instance.Instance, domain)
if err != nil {
return err
}
}
return nil
}, out, ctx, "Purging instance-specific resources"); err != nil {
return errPurgeGeneric.WithMessageF(slug, err)
}
// remove from bookkeeping
logging.LogMessage(out, ctx, "Removing instance from bookkeeping")
if err := instance.Bookkeeping().Delete(ctx); err != nil {
fmt.Fprintln(out, err)
}
// remove the filesystem
logging.LogMessage(out, ctx, "Remove lock data")
if instance.Locker().TryUnlock(ctx) {
fmt.Fprintln(out, "instance was not locked")
}
return nil
}

View file

@ -14,7 +14,8 @@ type Routeable interface {
// Routes returns information about the routes to be handled by this Routeable
Routes() Routes
// HandleRoute returns the handler for the requested path
// HandleRoute returns the handler for the requested path.
// Context is cancelled once the handler should be closed.
HandleRoute(ctx context.Context, path string) (http.Handler, error)
}

View file

@ -23,6 +23,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter/logger"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances/malt"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances/purger"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/meta"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/resolver"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/solr"
@ -119,6 +120,10 @@ func (dis *Distillery) Custom() *custom.Custom {
return export[*custom.Custom](dis)
}
func (dis *Distillery) Purger() *purger.Purger {
return export[*purger.Purger](dis)
}
//
// All components
// THESE SHOULD NEVER BE CALLED DIRECTLY
@ -156,6 +161,9 @@ func (dis *Distillery) allComponents() []initFunc {
auto[*meta.Meta],
auto[*malt.Malt],
// Purger
auto[*purger.Purger],
// Snapshots
auto[*exporter.Exporter],
auto[*logger.Logger],