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

@ -3,21 +3,21 @@ 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/pkg/fsx"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/provision"
"github.com/FAU-CDI/wisski-distillery/pkg/logging"
"github.com/tkw1536/goprogram/exit"
)
// Provision is the 'provision' command
var Provision wisski_distillery.Command = provision{}
var Provision wisski_distillery.Command = pv{}
type provision struct {
type pv struct {
Positionals struct {
Slug string `positional-arg-name:"slug" required:"1-1" description:"slug of instance to create"`
} `positional-args:"true"`
}
func (provision) Description() wisski_distillery.Description {
func (pv) Description() wisski_distillery.Description {
return wisski_distillery.Description{
Requirements: cli.Requirements{
NeedsDistillery: true,
@ -27,82 +27,19 @@ func (provision) Description() wisski_distillery.Description {
}
}
// TODO: AfterParse to check instance!
var errProvisionAlreadyExists = exit.Error{
Message: "instance %q already exists",
ExitCode: exit.ExitGeneric,
}
var errProvisionGeneric = exit.Error{
Message: "unable to provision instance %s: %s",
ExitCode: exit.ExitGeneric,
}
func (p provision) Run(context wisski_distillery.Context) error {
dis := context.Environment
slug := p.Positionals.Slug
// TODO: AfterParse to check instance!
// check that it doesn't already exist
logging.LogMessage(context.Stderr, context.Context, "Provisioning new WissKI instance %s", slug)
if exists, err := dis.Instances().Has(context.Context, slug); err != nil || exists {
return errProvisionAlreadyExists.WithMessageF(slug)
}
// make it in-memory
instance, err := dis.Instances().Create(slug)
func (p pv) Run(context wisski_distillery.Context) error {
instance, err := context.Environment.Provision().Provision(context.Stderr, context.Context, provision.ProvisionFlags{
Slug: p.Positionals.Slug,
})
if err != nil {
return errProvisionGeneric.WithMessageF(slug, err)
}
// check that the base directory does not exist
logging.LogMessage(context.Stderr, context.Context, "Checking that base directory %s does not exist", instance.FilesystemBase)
if fsx.IsDirectory(dis.Environment, instance.FilesystemBase) {
return errProvisionAlreadyExists.WithMessageF(slug)
}
// Store in the instances table!
if err := logging.LogOperation(func() error {
if err := instance.Bookkeeping().Save(context.Context); err != nil {
return errProvisionGeneric.WithMessageF(slug, err)
}
return nil
}, context.Stderr, context.Context, "Updating bookkeeping database"); err != nil {
return err
}
// create all the resources!
if err := logging.LogOperation(func() error {
domain := instance.Domain()
for _, pc := range dis.Provisionable() {
logging.LogMessage(context.Stderr, context.Context, "Provisioning %s resources", pc.Name())
err := pc.Provision(context.Context, instance.Instance, domain)
if err != nil {
return err
}
}
return nil
}, context.Stderr, context.Context, "Provisioning instance-specific resources"); err != nil {
return errProvisionGeneric.WithMessageF(slug, err)
}
// run the provision script
if err := logging.LogOperation(func() error {
if err := instance.Provisioner().Provision(context.Context, context.Stderr); err != nil {
return errProvisionGeneric.WithMessageF(slug, err)
}
return nil
}, context.Stderr, context.Context, "Running setup scripts"); err != nil {
return err
}
// start the container!
logging.LogMessage(context.Stderr, context.Context, "Starting Container")
if err := instance.Barrel().Stack().Up(context.Context, context.Stderr); err != nil {
return err
return errProvisionGeneric.WithMessageF(p.Positionals.Slug, err)
}
// and we're done!

View file

@ -47,7 +47,7 @@ func (r reserve) Run(context wisski_distillery.Context) error {
// check that it doesn't already exist
logging.LogMessage(context.Stderr, context.Context, "Reserving new WissKI instance %s", slug)
if exists, err := dis.Instances().Has(context.Context, slug); err != nil || exists {
return errProvisionAlreadyExists.WithMessageF(slug)
return errReserveAlreadyExists.WithMessageF(slug)
}
// make it in-memory
@ -59,7 +59,7 @@ func (r reserve) Run(context wisski_distillery.Context) error {
// check that the base directory does not exist
logging.LogMessage(context.Stderr, context.Context, "Checking that base directory %s does not exist", instance.FilesystemBase)
if fsx.IsDirectory(dis.Environment, instance.FilesystemBase) {
return errProvisionAlreadyExists.WithMessageF(slug)
return errReserveAlreadyExists.WithMessageF(slug)
}
// setup docker stack

View file

@ -1,7 +1,7 @@
package cli
// ===========================================================================================================
// This file was generated automatically at 26-02-2023 19:08:42 using gogenlicense.
// This file was generated automatically at 27-02-2023 10:11:35 using gogenlicense.
// Do not edit manually, as changes may be overwritten.
// ===========================================================================================================
@ -2452,7 +2452,7 @@ package cli
// # Generation
//
// This variable and the associated documentation have been automatically generated using the 'gogenlicense' tool.
// It was last updated at 26-02-2023 19:08:42.
// It was last updated at 27-02-2023 10:11:35.
var LegalNotices string
func init() {

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,8 +46,9 @@ function makeTextBuffer(target: HTMLElement, scrollContainer: HTMLElement, size:
return println;
}
const remote_action = document.getElementsByClassName('remote-action')
Array.from(remote_action).forEach((element) => {
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;
@ -77,12 +78,36 @@ Array.from(remote_action).forEach((element) => {
runValidation()
}
let onClose: (success: boolean) => void | null;
if (typeof reload === 'string') {
onClose = () => {
if (reload === '') location.reload();
else location.href = reload;
}
}
element.addEventListener('click', function (ev) {
ev.preventDefault();
// do nothing if the validation fails
if (!validate()) return;
// 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"
@ -90,25 +115,21 @@ Array.from(remote_action).forEach((element) => {
// create a <pre> to write stuff into
const target = document.createElement("pre")
const println = makeTextBuffer(target, modal, bufferSize)
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 reload === 'string' ? "Close & Reload" : "Close")
button.append(typeof opts?.onClose === 'function' ? "Close & Finish" : "Close")
let success = false;
button.addEventListener('click', function (event) {
event.preventDefault();
if (typeof reload === 'string') {
if (typeof opts?.onClose === 'function') {
button.setAttribute('disabled', 'disabled')
target.innerHTML = 'Reloading page ...'
if (reload === '') {
location.reload()
} else {
location.href = reload
}
target.innerHTML = 'Finishing up ...'
opts.onClose(success)
return;
}
@ -136,73 +157,17 @@ Array.from(remote_action).forEach((element) => {
// connect to the socket and send the action
connectSocket((socket) => {
println("Connected", true)
socket.send(action);
if (typeof param === 'string') {
socket.send(param);
}
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();
});
});
})
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);
}
connectSocket((socket) => {
socket.send(action);
if (params) {
params.forEach(p => socket.send(p))
}
}, (data) => {
buffer += data + "\n";
}).then(() => {
resolve();
}).catch(() => {
buffer = "false\n";
resolve();
});
})
}

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],