Add a form to provision a new instance

This commit is contained in:
Tom Wiesing 2023-02-27 11:10:28 +01:00
parent 80906d3791
commit 53f63d4efd
No known key found for this signature in database
25 changed files with 367 additions and 236 deletions

View file

@ -0,0 +1,92 @@
package provision
import (
"context"
"errors"
"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/internal/wisski"
"github.com/FAU-CDI/wisski-distillery/pkg/fsx"
"github.com/FAU-CDI/wisski-distillery/pkg/logging"
)
type Provision struct {
component.Base
Dependencies struct {
Instances *instances.Instances
Provisionable []component.Provisionable
}
}
// ProvisionFlags are flags for a new instance
type ProvisionFlags struct {
Slug string
}
var ErrInstanceAlreadyExists = errors.New("instance with provided slug already exists")
// Provision provisions a new docker compose instance.
func (pv *Provision) Provision(progress io.Writer, ctx context.Context, flags ProvisionFlags) (*wisski.WissKI, error) {
// check that it doesn't already exist
logging.LogMessage(progress, ctx, "Provisioning new WissKI instance %s", flags.Slug)
if exists, err := pv.Dependencies.Instances.Has(ctx, flags.Slug); err != nil || exists {
return nil, ErrInstanceAlreadyExists
}
// make it in-memory
instance, err := pv.Dependencies.Instances.Create(flags.Slug)
if err != nil {
return nil, err
}
// check that the base directory does not exist
logging.LogMessage(progress, ctx, "Checking that base directory %s does not exist", instance.FilesystemBase)
if fsx.IsDirectory(pv.Environment, instance.FilesystemBase) {
return nil, ErrInstanceAlreadyExists
}
// Store in the instances table!
if err := logging.LogOperation(func() error {
if err := instance.Bookkeeping().Save(ctx); err != nil {
return err
}
return nil
}, progress, ctx, "Updating bookkeeping database"); err != nil {
return nil, err
}
// create all the resources!
if err := logging.LogOperation(func() error {
domain := instance.Domain()
for _, pc := range pv.Dependencies.Provisionable {
logging.LogMessage(progress, ctx, "Provisioning %s resources", pc.Name())
err := pc.Provision(ctx, instance.Instance, domain)
if err != nil {
return err
}
}
return nil
}, progress, ctx, "Provisioning instance-specific resources"); err != nil {
return nil, err
}
// run the provision script
if err := logging.LogOperation(func() error {
return instance.Provisioner().Provision(ctx, progress)
}, progress, ctx, "Running setup scripts"); err != nil {
return nil, err
}
// start the container!
logging.LogMessage(progress, ctx, "Starting Container")
if err := instance.Barrel().Stack().Up(ctx, progress); err != nil {
return nil, err
}
// and return the instance
return instance, nil
}

View file

@ -70,7 +70,9 @@ var (
menuUsers = component.MenuItem{Title: "Users", Path: "/admin/users/"}
menuUserCreate = component.MenuItem{Title: "Create User", Path: "/admin/users/create/"}
menuInstances = component.MenuItem{Title: "Instances", Path: "/admin/instance/"}
menuProvision = component.MenuItem{Title: "Provision", Path: "/admin/instances/provision/"}
menuInstances = component.MenuItem{Title: "Instances", Path: "/admin/instances/"}
menuInstance = component.DummyMenuItem()
menuGrants = component.DummyMenuItem()
menuIngredients = component.DummyMenuItem()
@ -94,10 +96,13 @@ func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http
router.Handler(http.MethodGet, route, index)
}
// add a handler for the instances page
// add a handler for the instances (and provision) page
{
instances := admin.instances(ctx)
router.Handler(http.MethodGet, route+"instance/", instances)
router.Handler(http.MethodGet, route+"instances/", instances)
provision := admin.instanceProvision(ctx)
router.Handler(http.MethodGet, route+"instances/provision", provision)
}
// add a handler for the user page

View file

@ -0,0 +1,11 @@
<div class="pure-u-1-1">
<form class="pure-form pure-form-aligned" id="provision">
<fieldset disabled="disabled">
<div class="pure-control-group">
<label for="slug">Slug</label>
<input name="slug" id="slug" placeholder="" autocomplete="slug">
</div>
<input type="submit" value="Provision" class="pure-button">
</fieldset>
</form>
</div>

View file

@ -130,6 +130,9 @@ func (admin *Admin) instances(ctx context.Context) http.Handler {
menuAdmin,
menuInstances,
),
templating.Actions(
menuProvision,
),
)
return tpl.HTMLHandler(func(r *http.Request) (idx indexContext, err error) {

View file

@ -0,0 +1,42 @@
package admin
import (
"context"
"net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/assets"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templating"
_ "embed"
)
//go:embed "html/instance_provision.html"
var instanceProvisionHTML []byte
var instanceProvisionTemplate = templating.Parse[instanceProvisionContext](
"instance_provision.html", instanceProvisionHTML, nil,
templating.Title("Provision New Instance"),
templating.Assets(assets.AssetsAdminProvision),
)
type instanceProvisionContext struct {
templating.RuntimeFlags
// nothing for the moment
}
func (admin *Admin) instanceProvision(ctx context.Context) http.Handler {
tpl := instanceProvisionTemplate.Prepare(
admin.Dependencies.Templating,
templating.Crumbs(
menuAdmin,
menuInstances,
menuProvision,
),
)
return tpl.HTMLHandler(func(r *http.Request) (ipc instanceProvisionContext, err error) {
return ipc, nil
})
}

View file

@ -2,9 +2,12 @@ package socket
import (
"context"
"encoding/json"
"fmt"
"io"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/provision"
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
)
@ -24,6 +27,32 @@ var actions = map[string]SocketAction{
)
},
},
"provision": {
NumParams: 1,
HandleInteractive: func(ctx context.Context, sockets *Sockets, out io.Writer, params ...string) error {
// read the flags of the instance to be provisioned
var flags provision.ProvisionFlags
if err := json.Unmarshal([]byte(params[0]), &flags); err != nil {
return err
}
instance, err := sockets.Dependencies.Provision.Provision(
out,
ctx,
flags,
)
if err != nil {
return err
}
fmt.Fprintf(out, "URL: %s\n", instance.URL().String())
fmt.Fprintf(out, "Username: %s\n", instance.DrupalUsername)
fmt.Fprintf(out, "Password: %s\n", instance.DrupalPassword)
return nil
},
},
}
// socket specific actions

View file

@ -10,6 +10,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances/purger"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/provision"
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
"github.com/tkw1536/goprogram/status"
"github.com/tkw1536/pkglib/httpx"
@ -19,6 +20,7 @@ type Sockets struct {
component.Base
Dependencies struct {
Provision *provision.Provision
Instances *instances.Instances
Exporter *exporter.Exporter
Purger *purger.Purger

View file

@ -21,4 +21,4 @@ type Assets struct {
Styles template.HTML // <link> tags inserted by the asset
}
//go:generate node build.mjs Default User Admin
//go:generate node build.mjs Default User Admin AdminProvision

View file

@ -24,6 +24,12 @@ var AssetsUser = Assets{
// AssetsAdmin contains assets for the 'Admin' entrypoint.
var AssetsAdmin = Assets{
Scripts: `<script nomodule="" defer src="/this-is-fine/User.b2f9a57c.js"></script><script type="module" src="/this-is-fine/User.e0367d79.js"></script><script type="module" src="/this-is-fine/Default.38d394c2.js"></script><script src="/this-is-fine/Default.38d394c2.js" nomodule="" defer></script><script type="module" src="/this-is-fine/Admin.6a4184fa.js"></script><script src="/this-is-fine/Admin.6f0f8426.js" nomodule="" defer></script>`,
Scripts: `<script nomodule="" defer src="/this-is-fine/User.b2f9a57c.js"></script><script type="module" src="/this-is-fine/User.e0367d79.js"></script><script type="module" src="/this-is-fine/Default.38d394c2.js"></script><script src="/this-is-fine/Default.38d394c2.js" nomodule="" defer></script><script type="module" src="/this-is-fine/Admin.620228db.js"></script><script src="/this-is-fine/Admin.939dcd95.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/this-is-fine/Default.938b4407.css"><link rel="stylesheet" href="/this-is-fine/Admin.a1e05c23.css"><link rel="stylesheet" href="/this-is-fine/User.840de3b4.css"><link rel="stylesheet" href="/this-is-fine/User.68febbf8.css"><link rel="stylesheet" href="/this-is-fine/Admin.6d2ae968.css">`,
}
// AssetsAdminProvision contains assets for the 'AdminProvision' entrypoint.
var AssetsAdminProvision = Assets{
Scripts: `<script nomodule="" defer src="/this-is-fine/User.b2f9a57c.js"></script><script nomodule="" defer src="/this-is-fine/Admin.939dcd95.js"></script><script type="module" src="/this-is-fine/User.e0367d79.js"></script><script type="module" src="/this-is-fine/Admin.620228db.js"></script><script type="module" src="/this-is-fine/Default.38d394c2.js"></script><script src="/this-is-fine/Default.38d394c2.js" nomodule="" defer></script><script type="module" src="/this-is-fine/AdminProvision.3cf9e19e.js"></script><script src="/this-is-fine/AdminProvision.d195fd59.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/this-is-fine/Default.938b4407.css"><link rel="stylesheet" href="/this-is-fine/Admin.a1e05c23.css"><link rel="stylesheet" href="/this-is-fine/User.840de3b4.css"><link rel="stylesheet" href="/this-is-fine/User.68febbf8.css"><link rel="stylesheet" href="/this-is-fine/Admin.6d2ae968.css"><link rel="stylesheet" href="/this-is-fine/AdminProvision.38d394c2.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

@ -0,0 +1 @@
var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{},n={},o={},t=e.parcelRequireafa4;null==t&&((t=function(e){if(e in n)return n[e].exports;if(e in o){var t=o[e];delete o[e];var r={id:e,exports:{}};return n[e]=r,t.call(r.exports,r,r.exports),r.exports}var i=new Error("Cannot find module '"+e+"'");throw i.code="MODULE_NOT_FOUND",i}).register=function(e,n){o[e]=n},e.parcelRequireafa4=t),t("8xGhL");var r=t("12vpF");const i=document.getElementById("provision"),l=document.getElementById("slug");i.addEventListener("submit",(e=>{e.preventDefault();const n={Slug:l.value};(0,r.createModal)("provision",[JSON.stringify(n)],{bufferSize:0,onClose:e=>{e?location.href="/admin/instance/"+n.Slug:location.reload()}})})),i.querySelector("fieldset")?.removeAttribute("disabled");

View file

@ -0,0 +1 @@
!function(){var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{},n={},o={},t=e.parcelRequireafa4;null==t&&((t=function(e){if(e in n)return n[e].exports;if(e in o){var t=o[e];delete o[e];var i={id:e,exports:{}};return n[e]=i,t.call(i.exports,i,i.exports),i.exports}var r=new Error("Cannot find module '"+e+"'");throw r.code="MODULE_NOT_FOUND",r}).register=function(e,n){o[e]=n},e.parcelRequireafa4=t),t("dK5Bi");var i,r=t("8vh0V");const l=document.getElementById("provision"),d=document.getElementById("slug");l.addEventListener("submit",(e=>{e.preventDefault();const n={Slug:d.value};(0,r.createModal)("provision",[JSON.stringify(n)],{bufferSize:0,onClose:e=>{e?location.href="/admin/instance/"+n.Slug:location.reload()}})})),null===(i=l.querySelector("fieldset"))||void 0===i||i.removeAttribute("disabled")}();

View file

@ -1,6 +1,10 @@
import "~/src/lib/remote"
// setup highlighting
import "~/src/lib/highlight"
// setup remote actions
import setup from "~/src/lib/remote"
setup();
// include the user styles!
import "../User/index.ts"
import "../User/index.css"

View file

@ -0,0 +1,32 @@
import "../Admin/index.ts"
import "../Admin/index.css"
import { createModal } from "~/src/lib/remote"
const provision = document.getElementById("provision") as HTMLFormElement;
const slug = document.getElementById("slug") as HTMLInputElement;
// add an event handler to open the modal form!
provision.addEventListener('submit', (evt) => {
evt.preventDefault();
// flags used to create the server
const flags = { Slug: slug.value };
// open a modal to provision a new instance
createModal("provision", [JSON.stringify(flags)], {
bufferSize: 0,
onClose: (success: boolean) => {
if (success) {
location.href = "/admin/instance/" + flags.Slug
} else {
location.reload();
}
},
})
})
// enable the form!
provision.querySelector('fieldset')?.removeAttribute('disabled');

View file

@ -46,163 +46,128 @@ function makeTextBuffer(target: HTMLElement, scrollContainer: HTMLElement, size:
return println;
}
const remote_action = document.getElementsByClassName('remote-action')
Array.from(remote_action).forEach((element) => {
const action = element.getAttribute('data-action') as string;
const reload = element.getAttribute('data-force-reload');
const param = element.getAttribute('data-param') as string | undefined;
export default function setup() {
const remote_action = document.getElementsByClassName('remote-action')
Array.from(remote_action).forEach((element) => {
const action = element.getAttribute('data-action') as string;
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 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')
}
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;
}
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"
document.body.append(modal)
// create a <pre> to write stuff into
const target = document.createElement("pre")
const println = makeTextBuffer(target, modal, bufferSize)
modal.append(target)
// create a button to eventually close everything
const button = document.createElement("button")
button.className = "pure-button pure-button-success"
button.append(typeof reload === 'string' ? "Close & Reload" : "Close")
button.addEventListener('click', function (event) {
event.preventDefault();
if (typeof reload === 'string') {
button.setAttribute('disabled', 'disabled')
target.innerHTML = 'Reloading page ...'
if (reload === '') {
location.reload()
if (confirmElement) {
const runValidation = () => {
if (validate()) {
element.removeAttribute('disabled')
} else {
location.href = reload
element.setAttribute('disabled', 'disabled')
}
return;
}
modal.parentNode?.removeChild(modal);
})
const onbeforeunload = window.onbeforeunload;
window.onbeforeunload = () => "A remote session is in progress. Are you sure you want to leave?";
// when closing, add a button to the modal!
let didClose = false
const close = function () {
if (didClose) return
didClose = true
window.onbeforeunload = onbeforeunload;
modal.append(button)
// DEBUG: print terminal stats!
// const quota = (println.paintedFrames / (println.missedFrames + println.paintedFrames)) * 100
// println(`Terminal: painted=${println.paintedFrames} missed=${println.missedFrames} (${quota}%)`, true)
confirmElement.addEventListener('change', runValidation)
runValidation()
}
println("Connecting ...", true)
// connect to the socket and send the action
connectSocket((socket) => {
println("Connected", true)
socket.send(action);
if (typeof param === 'string') {
socket.send(param);
let onClose: (success: boolean) => void | null;
if (typeof reload === 'string') {
onClose = () => {
if (reload === '') location.reload();
else location.href = reload;
}
}, (data) => {
println(data);
}).then(() => {
println("Connection closed.", true)
close();
}).catch(() => {
println("Connection errored.", true)
close();
});
});
})
const remote_link = document.getElementsByClassName('remote-link')
Array.from(remote_link).forEach((element) => {
const action = element.getAttribute('data-action') as string;
const param = element.getAttribute('data-params') as string | undefined;
const params = param?.split(" ");
element.addEventListener('click', function (ev) {
ev.preventDefault();
getValue(action, params).then(v => {
window.open(v);
}).catch(e => {
console.error(e);
})
});
})
async function getValue(action: string, params?: Array<string>): Promise<any> {
return new Promise((rs, rj) => {
let buffer = "";
var resolve = function() {
const index = buffer.indexOf('\n')
if (index < 0) {
rj("invalid buffer");
return
}
// check that the server sent back true
const ok = buffer.substring(0, index) === 'true';
if(!ok) {
rj(buffer);
return
}
// parse the rest as json
const value = JSON.parse(buffer.substring(index+1))
rs(value);
}
element.addEventListener('click', function (ev) {
ev.preventDefault();
// do nothing if the validation fails
if (!validate()) return;
connectSocket((socket) => {
socket.send(action);
if (params) {
params.forEach(p => socket.send(p))
}
}, (data) => {
buffer += data + "\n";
}).then(() => {
resolve();
}).catch(() => {
buffer = "false\n";
resolve();
// create a modal dialog
const params = (typeof param === 'string') ? [param] : [];
createModal(action, params, {
onClose: onClose,
bufferSize: bufferSize,
});
});
})
}
type ModalOptions = {
bufferSize: number;
onClose: (success: boolean) => void
}
export function createModal(action: string, params: string[], opts: Partial<ModalOptions>) {
// create a modal dialog and append it to the body
const modal = document.createElement("div")
modal.className = "modal-terminal"
document.body.append(modal)
// create a <pre> to write stuff into
const target = document.createElement("pre")
const println = makeTextBuffer(target, modal, opts.bufferSize ?? 1000)
modal.append(target)
// create a button to eventually close everything
const button = document.createElement("button")
button.className = "pure-button pure-button-success"
button.append(typeof opts?.onClose === 'function' ? "Close & Finish" : "Close")
let success = false;
button.addEventListener('click', function (event) {
event.preventDefault();
if (typeof opts?.onClose === 'function') {
button.setAttribute('disabled', 'disabled')
target.innerHTML = 'Finishing up ...'
opts.onClose(success)
return;
}
modal.parentNode?.removeChild(modal);
})
const onbeforeunload = window.onbeforeunload;
window.onbeforeunload = () => "A remote session is in progress. Are you sure you want to leave?";
// when closing, add a button to the modal!
let didClose = false
const close = function () {
if (didClose) return
didClose = true
window.onbeforeunload = onbeforeunload;
modal.append(button)
// DEBUG: print terminal stats!
// const quota = (println.paintedFrames / (println.missedFrames + println.paintedFrames)) * 100
// println(`Terminal: painted=${println.paintedFrames} missed=${println.missedFrames} (${quota}%)`, true)
}
println("Connecting ...", true)
// connect to the socket and send the action
connectSocket((socket) => {
println("Connected", true)
socket.send(action)
params.forEach(p => socket.send(p))
}, (data) => {
println(data);
}).then(() => {
success = true
println("Connection closed.", true)
close();
}).catch(() => {
success = false
println("Connection errored.", true)
close();
});
}

View file

@ -31,7 +31,6 @@ func (flags Flags) Apply(r *http.Request, funcs ...FlagFunc) Flags {
}
// RuntimeFlags are passed to the template at runtime.
// Any context may e
type RuntimeFlags struct {
Flags

View file

@ -17,6 +17,7 @@ import (
"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/provision"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/resolver"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/admin"
@ -102,6 +103,9 @@ func (dis *Distillery) Instances() *instances.Instances {
func (dis *Distillery) Exporter() *exporter.Exporter {
return export[*exporter.Exporter](dis)
}
func (dis *Distillery) Provision() *provision.Provision {
return export[*provision.Provision](dis)
}
func (dis *Distillery) Installable() []component.Installable {
return exportAll[component.Installable](dis)
@ -109,9 +113,6 @@ func (dis *Distillery) Installable() []component.Installable {
func (dis *Distillery) Updatable() []component.Updatable {
return exportAll[component.Updatable](dis)
}
func (dis *Distillery) Provisionable() []component.Provisionable {
return exportAll[component.Provisionable](dis)
}
func (dis *Distillery) Info() *admin.Admin {
return export[*admin.Admin](dis)
}
@ -162,6 +163,7 @@ func (dis *Distillery) allComponents() []initFunc {
auto[*instances.Instances],
auto[*meta.Meta],
auto[*malt.Malt],
auto[*provision.Provision],
// Purger
auto[*purger.Purger],