internal/component => internal/dis/component

This commit is contained in:
Tom Wiesing 2022-10-18 09:40:37 +02:00
parent 9443217441
commit b5b1ce2340
No known key found for this signature in database
123 changed files with 76 additions and 76 deletions

View file

@ -0,0 +1,171 @@
package component
import (
"io"
"path/filepath"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
"github.com/FAU-CDI/wisski-distillery/pkg/fsx"
"github.com/pkg/errors"
"github.com/tkw1536/goprogram/stream"
)
// Backupable represents a component with a Backup method
type Backupable interface {
Component
// BackupName returns a new name to be used as an argument for path.
BackupName() string
// Backup backs up this component into the destination path path
Backup(context StagingContext) error
}
// Snapshotable represents a component with a Snapshot method.
type Snapshotable interface {
Component
// SnapshotNeedsRunning returns if this Snapshotable requires a running instance.
SnapshotNeedsRunning() bool
// SnapshotName returns a new name to be used as an argument for path.
SnapshotName() string
// Snapshot snapshots a part of the instance
Snapshot(wisski models.Instance, context StagingContext) error
}
// StagingContext represents a context for [Backupable] and [Snapshotable]
type StagingContext interface {
// IO returns the input output stream belonging to this backup file
IO() stream.IOStream
// Name creates a new directory inside the destination.
// Passing the empty path creates the destination as a directory.
//
// It then allows op to fill the file.
AddDirectory(path string, op func() error) error
// CopyFile copies a file from src to dst.
CopyFile(dst, src string) error
// CopyDirectory copies a directory from src to dst.
CopyDirectory(dst, src string) error
// AddFile creates a new file at the provided path inside the destination.
// Passing the empty path creates the destination as a file.
//
// It then allows op to write to the file.
//
// The op function must not retain file.
// The underlying file does not need to be closed.
// AddFile will not return before op has returned.
AddFile(path string, op func(file io.Writer) error) error
}
// NewStagingContext returns a new [StagingContext]
func NewStagingContext(env environment.Environment, io stream.IOStream, path string, manifest chan<- string) StagingContext {
return &stagingContext{
env: env,
io: io,
path: path,
manifest: manifest,
}
}
// stagingContext implements [components.StagingContext]
type stagingContext struct {
env environment.Environment // environment
io stream.IOStream // context the files are sent to
path string // path to send files to
manifest chan<- string // channel the manifest is sent to
}
func (bc *stagingContext) sendPath(path string) {
// resolve the path, or bail out!
// TODO: Use the relative path here!
dst, err := bc.resolve(path)
if err != nil {
return
}
bc.io.Println(dst)
bc.manifest <- dst
}
func (bc *stagingContext) IO() stream.IOStream {
return bc.io
}
var errResolveAbsolute = errors.New("resolve: path must be relative")
func (bc *stagingContext) resolve(path string) (dest string, err error) {
if path == "" {
return bc.path, nil
}
if filepath.IsAbs(path) {
return "", errResolveAbsolute
}
return filepath.Join(bc.path, path), nil
}
func (sc *stagingContext) AddDirectory(path string, op func() error) error {
// resolve the path!
dst, err := sc.resolve(path)
if err != nil {
return err
}
// run the make directory
if err := sc.env.Mkdir(dst, environment.DefaultDirPerm); err != nil {
return err
}
// tell the files that we are creating it!
sc.sendPath(path)
// and run the files!
return op()
}
func (sc *stagingContext) CopyFile(dst, src string) error {
dstPath, err := sc.resolve(dst)
if err != nil {
return err
}
sc.sendPath(dst)
return fsx.CopyFile(sc.env, dstPath, src)
}
func (sc *stagingContext) CopyDirectory(dst, src string) error {
dstPath, err := sc.resolve(dst)
if err != nil {
return err
}
return fsx.CopyDirectory(sc.env, dstPath, src, func(dst, src string) {
sc.sendPath(dst)
})
}
func (sc *stagingContext) AddFile(path string, op func(file io.Writer) error) error {
// resolve the path!
dst, err := sc.resolve(path)
if err != nil {
return err
}
// create the file
file, err := sc.env.Create(dst, environment.DefaultFilePerm)
if err != nil {
return err
}
defer file.Close()
// tell them that we are creating it!
sc.sendPath(path)
// and do whatever they wanted to do
return op(file)
}

View file

@ -0,0 +1,57 @@
// Package component holds the main abstraction for components.
package component
import (
"reflect"
"strings"
"github.com/FAU-CDI/wisski-distillery/internal/config"
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
)
// Components represents a logical subsystem of the distillery.
// A Component should be implemented as a pointer to a struct.
// Every component must embed [Base] and should be initialized using [Init] inside a [lazy.Pool].
//
// By convention these are defined within their corresponding subpackage.
// This subpackage also contains all required resources.
type Component interface {
// Name returns the name of this component
// Name should be implemented by the [ComponentBase] struct.
Name() string
// getBase returns the underlying ComponentBase object of this Component.
// It is used internally during initialization
getBase() *Base
}
// Base is embedded into every Component
type Base struct {
name string // name is the name of this component
Still // the underlying still of the distillery
}
//lint:ignore U1000 used to implement the private methods of [Component]
func (cb *Base) getBase() *Base {
return cb
}
// Init initialzes a new componeont Component with the provided still.
// Init is only initended to be used within a lazy.Pool[Component,Still].
func Init(component Component, core Still) Component {
base := component.getBase() // pointer to a struct
base.Still = core
base.name = strings.ToLower(reflect.TypeOf(component).Elem().Name())
return component
}
func (cb Base) Name() string {
return cb.name
}
// Still represents the central part of a distillery.
// It is used inside the main distillery struct, as well as every component via [ComponentBase].
type Still struct {
Environment environment.Environment // environment to use for reading / writing to and from the distillery
Config *config.Config // the configuration of the distillery
}

View file

@ -0,0 +1,10 @@
HOST_RULE=${HOST_RULE}
CONFIG_PATH=${CONFIG_PATH}
DEPLOY_ROOT=${DEPLOY_ROOT}
GLOBAL_AUTHORIZED_KEYS_FILE=${GLOBAL_AUTHORIZED_KEYS_FILE}
SELF_OVERRIDES_FILE=${SELF_OVERRIDES_FILE}
SELF_RESOLVER_BLOCK_FILE=${SELF_RESOLVER_BLOCK_FILE}
DOCKER_NETWORK_NAME=${DOCKER_NETWORK_NAME}
HTTPS_ENABLED=${HTTPS_ENABLED}

View file

@ -0,0 +1,54 @@
package control
import (
"embed"
"path/filepath"
"github.com/FAU-CDI/wisski-distillery/internal/bootstrap"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
)
// Control represents the running control server.
type Control struct {
component.Base
Servables []component.Servable
}
func (control Control) Path() string {
return filepath.Join(control.Still.Config.DeployRoot, "core", "dis")
}
//go:embed all:control control.env
var resources embed.FS
func (control *Control) Stack(env environment.Environment) component.StackWithResources {
stt := component.MakeStack(control, env, component.StackWithResources{
Resources: resources,
ContextPath: "control",
EnvPath: "control.env",
EnvContext: map[string]string{
"DOCKER_NETWORK_NAME": control.Config.DockerNetworkName,
"HOST_RULE": control.Config.DefaultHostRule(),
"HTTPS_ENABLED": control.Config.HTTPSEnabledEnv(),
"CONFIG_PATH": control.Config.ConfigPath,
"DEPLOY_ROOT": control.Config.DeployRoot,
"GLOBAL_AUTHORIZED_KEYS_FILE": control.Config.GlobalAuthorizedKeysFile,
"SELF_OVERRIDES_FILE": control.Config.SelfOverridesFile,
"SELF_RESOLVER_BLOCK_FILE": control.Config.SelfResolverBlockFile,
},
CopyContextFiles: []string{bootstrap.Executable},
})
return stt
}
func (control Control) Context(parent component.InstallationContext) component.InstallationContext {
return component.InstallationContext{
bootstrap.Executable: control.Config.CurrentExecutable(control.Environment), // TODO: Does this make sense?
}
}

View file

@ -0,0 +1,5 @@
FROM docker.io/library/docker:20.10-cli
COPY wdcli /wdcli
EXPOSE 8888
CMD ["/wdcli","--internal-in-docker","server","--bind","0.0.0.0:8888"]

View file

@ -0,0 +1,35 @@
version: "3.7"
services:
dis:
build: .
restart: always
environment:
CONFIG_PATH: ${CONFIG_PATH}
labels:
- "traefik.enable=True"
- "eu.wiss-ki.barrel.distillery=${DOCKER_NETWORK_NAME}"
- "traefik.http.routers.control.rule=${HOST_RULE}"
- "traefik.http.routers.fallback.rule=HostRegexp(`{catchall:.*}`)"
- "traefik.http.routers.fallback.priority=1"
- "traefik.http.routers.control.tls=${HTTPS_ENABLED}"
- "traefik.http.routers.control.tls.certresolver=distillery"
- "traefik.http.services.control.loadbalancer.server.port=8888"
volumes:
# TODO: Mount docker socket properly!
- "/var/run/docker.sock:/var/run/docker.sock"
- "${CONFIG_PATH}:${CONFIG_PATH}:ro"
- "${DEPLOY_ROOT}:${DEPLOY_ROOT}:rw"
- "${GLOBAL_AUTHORIZED_KEYS_FILE}:${GLOBAL_AUTHORIZED_KEYS_FILE}:ro"
- "${SELF_OVERRIDES_FILE}:${SELF_OVERRIDES_FILE}:ro"
- "${SELF_RESOLVER_BLOCK_FILE}:${SELF_RESOLVER_BLOCK_FILE}:ro"
networks:
default:
name: ${DOCKER_NETWORK_NAME}
external: true

View file

@ -0,0 +1,30 @@
package control
import (
"context"
"net/http"
"github.com/tkw1536/goprogram/stream"
)
// Server returns an http.Mux that implements the main server instance.
// The server may spawn background tasks, but these should be terminated once context closes.
//
// Logging messages are directed to io.
func (control *Control) Server(context context.Context, io stream.IOStream) (*http.ServeMux, error) {
// create a new mux
mux := http.NewServeMux()
// add all the servable routes!
for _, s := range control.Servables {
for _, route := range s.Routes() {
io.Printf("mounting %s\n", route)
handler, err := s.Handler(route, context, io)
if err != nil {
return nil, err
}
mux.Handle(route, handler)
}
}
return mux, nil
}

View file

@ -0,0 +1,169 @@
package exporter
import (
"fmt"
"io"
"path/filepath"
"time"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
"github.com/FAU-CDI/wisski-distillery/pkg/logging"
"github.com/tkw1536/goprogram/status"
"github.com/tkw1536/goprogram/stream"
"golang.org/x/exp/slices"
)
// Backup describes a backup
type Backup struct {
Description BackupDescription
// Start and End Time of the backup
StartTime time.Time
EndTime time.Time
// various error states, which are ignored when creating the snapshot
ErrPanic interface{}
// errors for the various components
ComponentErrors map[string]error
// TODO: Make this proper
ConfigFileErr error
// Snapshots containing instances
InstanceListErr error
InstanceSnapshots []Snapshot
// List of files included
WithManifest
}
// BackupDescription provides a description for a backup
type BackupDescription struct {
Dest string // Destination path
ConcurrentSnapshots int // maximum number of concurrent snapshots
}
// New create a new Backup
func (exporter *Exporter) NewBackup(io stream.IOStream, description BackupDescription) (backup Backup) {
backup.Description = description
// catch anything critical that happened during the snapshot
defer func() {
backup.ErrPanic = recover()
}()
// do the create keeping track of time!
logging.LogOperation(func() error {
backup.StartTime = time.Now().UTC()
backup.run(io, exporter)
backup.EndTime = time.Now().UTC()
return nil
}, io, "Writing backup files")
return
}
func (backup *Backup) run(ios stream.IOStream, exporter *Exporter) {
// create a manifest
manifest, done := backup.handleManifest(backup.Description.Dest)
defer done()
// create a new status display
backups := exporter.Backupable
backup.ComponentErrors = make(map[string]error, len(backups))
// Component backup tasks
logging.LogOperation(func() error {
st := status.NewWithCompat(ios.Stdout, 0)
st.Start()
defer st.Stop()
errors := status.Group[component.Backupable, error]{
PrefixString: func(item component.Backupable, index int) string {
return fmt.Sprintf("[backup %q]: ", item.Name())
},
PrefixAlign: true,
Handler: func(bc component.Backupable, index int, writer io.Writer) error {
return bc.Backup(
component.NewStagingContext(
exporter.Environment,
stream.NewIOStream(writer, writer, nil, 0),
filepath.Join(backup.Description.Dest, bc.BackupName()),
manifest,
),
)
},
ResultString: status.DefaultErrorString[component.Backupable],
}.Use(st, backups)
for i, bc := range backups {
backup.ComponentErrors[bc.Name()] = errors[i]
}
return nil
}, ios, "Backing up core components")
// backup instances
logging.LogOperation(func() error {
st := status.NewWithCompat(ios.Stdout, 0)
st.Start()
defer st.Stop()
instancesBackupDir := filepath.Join(backup.Description.Dest, "instances")
if err := exporter.Environment.Mkdir(instancesBackupDir, environment.DefaultDirPerm); err != nil {
backup.InstanceListErr = err
return nil
}
// list all instances
wissKIs, err := exporter.Instances.All()
if err != nil {
backup.InstanceListErr = err
return nil
}
// make a backup of the snapshots
backup.InstanceSnapshots = status.Group[*wisski.WissKI, Snapshot]{
PrefixString: func(item *wisski.WissKI, index int) string {
return fmt.Sprintf("[snapshot %q]: ", item.Slug)
},
PrefixAlign: true,
Handler: func(instance *wisski.WissKI, index int, writer io.Writer) Snapshot {
dir := filepath.Join(instancesBackupDir, instance.Slug)
if err := exporter.Environment.Mkdir(dir, environment.DefaultDirPerm); err != nil {
return Snapshot{
ErrPanic: err,
}
}
manifest <- dir
return exporter.NewSnapshot(instance, stream.NewIOStream(writer, writer, nil, 0), SnapshotDescription{
Dest: dir,
})
},
ResultString: func(res Snapshot, item *wisski.WissKI, index int) string {
return "done"
},
WaitString: status.DefaultWaitString[*wisski.WissKI],
HandlerLimit: backup.Description.ConcurrentSnapshots,
}.Use(st, wissKIs)
// sort the instances
slices.SortFunc(backup.InstanceSnapshots, func(a, b Snapshot) bool {
return a.Instance.Slug < b.Instance.Slug
})
return nil
}, ios, "Creating instance snapshots")
}

View file

@ -0,0 +1,81 @@
package exporter
import (
"fmt"
"path/filepath"
"time"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"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/sql"
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
"github.com/FAU-CDI/wisski-distillery/pkg/fsx"
"github.com/FAU-CDI/wisski-distillery/pkg/password"
)
// Exporter manages snapshots and backups
type Exporter struct {
component.Base
SQL *sql.SQL
Instances *instances.Instances
ExporterLogger *logger.Logger
Snapshotable []component.Snapshotable
Backupable []component.Backupable
}
// Path returns the path that contains all snapshot related data.
func (dis *Exporter) Path() string {
return filepath.Join(dis.Config.DeployRoot, "snapshots")
}
// StagingPath returns the path to the directory containing a temporary staging area for snapshots.
// Use NewSnapshotStagingDir to generate a new staging area.
func (dis *Exporter) StagingPath() string {
return filepath.Join(dis.Path(), "staging")
}
// ArchivePath returns the path to the directory containing all exported archives.
// Use NewSnapshotArchivePath to generate a path to a new archive in this directory.
func (dis *Exporter) ArchivePath() string {
return filepath.Join(dis.Path(), "archives")
}
// NewArchivePath returns the path to a new archive with the provided prefix.
// The path is guaranteed to not exist.
func (dis *Exporter) NewArchivePath(prefix string) (path string) {
// TODO: Consider moving these into a subdirectory with the provided prefix.
for path == "" || fsx.Exists(dis.Environment, path) {
name := dis.newSnapshotName(prefix) + ".tar.gz"
path = filepath.Join(dis.ArchivePath(), name)
}
return
}
// newSnapshot name returns a new basename for a snapshot with the provided prefix.
// The name is guaranteed to be unique within this process.
func (*Exporter) newSnapshotName(prefix string) string {
suffix, _ := password.Password(10) // silently ignore any errors!
if prefix == "" {
prefix = "backup"
} else {
prefix = "snapshot-" + prefix
}
return fmt.Sprintf("%s-%d-%s", prefix, time.Now().Unix(), suffix)
}
// NewStagingDir returns the path to a new snapshot directory.
// The directory is guaranteed to have been freshly created.
func (dis *Exporter) NewStagingDir(prefix string) (path string, err error) {
for path == "" || environment.IsExist(err) {
path = filepath.Join(dis.StagingPath(), dis.newSnapshotName(prefix))
err = dis.Still.Environment.Mkdir(path, environment.DefaultFilePerm)
}
if err != nil {
path = ""
}
return
}

View file

@ -0,0 +1,27 @@
package exporter
import (
"fmt"
"io"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/models"
)
type Bookkeeping struct {
component.Base
}
// SnapshotNeedsRunning returns if this Snapshotable requires a running instance.
func (Bookkeeping) SnapshotNeedsRunning() bool { return false }
// SnapshotName returns a new name to be used as an argument for path.
func (Bookkeeping) SnapshotName() string { return "bookkeeping.txt" }
// Snapshot creates a snapshot of this instance
func (*Bookkeeping) Snapshot(wisski models.Instance, context component.StagingContext) error {
return context.AddFile(".", func(file io.Writer) error {
_, err := fmt.Fprintf(file, "%#v\n", wisski)
return err
})
}

View file

@ -0,0 +1,41 @@
package exporter
import (
"path/filepath"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
)
// Config implements backing up configuration
type Config struct {
component.Base
}
func (*Config) BackupName() string {
return "config"
}
func (control *Config) Backup(context component.StagingContext) error {
files := control.backupFiles()
return context.AddDirectory("", func() error {
for _, src := range files {
name := filepath.Base(src)
if err := context.CopyFile(name, src); err != nil {
return err
}
}
return nil
})
}
// backupfiles lists the files to be backed up.
func (control *Config) backupFiles() []string {
return []string{
control.Config.ConfigPath,
control.Config.ExecutablePath(),
control.Config.SelfOverridesFile,
control.Config.SelfResolverBlockFile,
control.Config.GlobalAuthorizedKeysFile,
}
}

View file

@ -0,0 +1,22 @@
package exporter
import (
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/models"
)
// Filesystem implements snapshotting an instnace filesystem
type Filesystem struct {
component.Base
}
// SnapshotNeedsRunning returns if this Snapshotable requires a running instance.
func (Filesystem) SnapshotNeedsRunning() bool { return false }
// SnapshotName returns a new name to be used as an argument for path.
func (Filesystem) SnapshotName() string { return "data" }
// Snapshot creates a snapshot of this instance
func (*Filesystem) Snapshot(wisski models.Instance, context component.StagingContext) error {
return context.CopyDirectory(".", wisski.FilesystemBase)
}

View file

@ -0,0 +1,37 @@
package exporter
import (
"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/models"
)
type Pathbuilders struct {
component.Base
Instances *instances.Instances
}
func (Pathbuilders) SnapshotNeedsRunning() bool { return true }
func (Pathbuilders) SnapshotName() string { return "pathbuilders" }
func (pbs *Pathbuilders) Snapshot(wisski models.Instance, context component.StagingContext) error {
return context.AddDirectory(".", func() error {
builders, err := pbs.Instances.Instance(wisski).AllPathbuilders(nil)
if err != nil {
return err
}
for name, bytes := range builders {
if err := context.AddFile(name+".xml", func(file io.Writer) error {
_, err := file.Write([]byte(bytes))
return err
}); err != nil {
return err
}
}
return nil
})
}

View file

@ -0,0 +1,166 @@
package exporter
import (
"io"
"path/filepath"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
"github.com/FAU-CDI/wisski-distillery/pkg/logging"
"github.com/FAU-CDI/wisski-distillery/pkg/targz"
"github.com/tkw1536/goprogram/status"
"github.com/tkw1536/goprogram/stream"
)
// ExportTask describes a task that makes either a [Backup] or a [Snapshot].
// See [Exporter.MakeExport]
type ExportTask struct {
// Dest is the destination path to write the backup to.
// When empty, this is created automatically in the staging or archive directory.
Dest string
// By default, a .tar.gz file is generated.
// To generated an unpacked directory, set [StagingOnly] to true.
StagingOnly bool
// Instance is the instance to generate a snapshot of.
// To generate a backup, leave this to be nil.
Instance *wisski.WissKI
// BackupDescriptions and SnapshotDescriptions further specitfy options for the export.
// The Dest parameter is ignored, and updated automatically.
BackupDescription BackupDescription
SnapshotDescription SnapshotDescription
}
// export is implemented by [Backup] and [Snapshot]
type export interface {
LogEntry() models.Export
Report(w io.Writer) (int, error)
}
// MakeExport performs an export task as described by flags.
// Output is directed to the provided io.
func (exporter *Exporter) MakeExport(io stream.IOStream, task ExportTask) (err error) {
// extract parameters
Title := "Backup"
Slug := ""
if task.Instance != nil {
Title = "Snapshot"
Slug = task.Instance.Slug
}
// determine target paths
logging.LogMessage(io, "Determining target paths")
var stagingDir, archivePath string
if task.StagingOnly {
stagingDir = task.Dest
} else {
archivePath = task.Dest
}
if stagingDir == "" {
stagingDir, err = exporter.NewStagingDir(Slug)
if err != nil {
return err
}
}
if !task.StagingOnly && archivePath == "" {
archivePath = exporter.NewArchivePath(Slug)
}
io.Printf("Staging Directory: %s\n", stagingDir)
io.Printf("Archive Path: %s\n", archivePath)
// create the staging directory
logging.LogMessage(io, "Creating staging directory")
err = exporter.Environment.Mkdir(stagingDir, environment.DefaultDirPerm)
if !environment.IsExist(err) && err != nil {
return err
}
// if it was requested to not do staging only
// we need the staging directory to be deleted at the end
if !task.StagingOnly {
defer func() {
logging.LogMessage(io, "Removing staging directory")
exporter.Environment.RemoveAll(stagingDir)
}()
}
// create the actual snapshot or backup
// write out the report
// and retain a log entry
var entry models.Export
logging.LogOperation(func() error {
var sl export
if task.Instance == nil {
task.BackupDescription.Dest = stagingDir
backup := exporter.NewBackup(io, task.BackupDescription)
sl = &backup
} else {
task.SnapshotDescription.Dest = stagingDir
snapshot := exporter.NewSnapshot(task.Instance, io, task.SnapshotDescription)
sl = &snapshot
}
// create a log entry
entry = sl.LogEntry()
// find the report path
reportPath := filepath.Join(stagingDir, "report.txt")
io.Println(reportPath)
// create the path
report, err := exporter.Environment.Create(reportPath, environment.DefaultFilePerm)
if err != nil {
return err
}
// and write out the report
{
_, err := sl.Report(report)
return err
}
}, io, "Generating %s", Title)
// if we only requested staging
// all that is left is to write the log entry
if task.StagingOnly {
logging.LogMessage(io, "Writing Log Entry")
// write out the log entry
entry.Path = stagingDir
entry.Packed = false
exporter.ExporterLogger.Add(entry)
io.Printf("Wrote %s\n", stagingDir)
return nil
}
// package everything up as an archive!
if err := logging.LogOperation(func() error {
var count int64
defer func() { io.Printf("Wrote %d byte(s) to %s\n", count, archivePath) }()
st := status.NewWithCompat(io.Stdout, 1)
st.Start()
defer st.Stop()
count, err = targz.Package(exporter.Environment, archivePath, stagingDir, func(dst, src string) {
st.Set(0, dst)
})
return err
}, io, "Writing archive"); err != nil {
return err
}
// write out the log entry
logging.LogMessage(io, "Writing Log Entry")
entry.Path = archivePath
entry.Packed = true
exporter.ExporterLogger.Add(entry)
// and we're done!
return nil
}

View file

@ -0,0 +1,17 @@
package exporter
import "github.com/FAU-CDI/wisski-distillery/internal/models"
func (backup *Backup) LogEntry() models.Export {
return models.Export{
Created: backup.StartTime,
Slug: "",
}
}
func (snapshot *Snapshot) LogEntry() models.Export {
return models.Export{
Created: snapshot.StartTime,
Slug: snapshot.Instance.Slug,
}
}

View file

@ -0,0 +1,78 @@
package logger
import (
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
"github.com/tkw1536/goprogram/lib/collection"
)
// Logger is responsible for logging backups and snapshots
type Logger struct {
component.Base
SQL *sql.SQL
}
// For retrieves (and prunes) the ExportLog.
// Slug determines if entries for Backups (empty slug)
// or a specific Instance (non-empty slug) are returned.
func (log *Logger) For(slug string) (exports []models.Export, err error) {
exports, err = log.Log()
if err != nil {
return nil, err
}
return collection.Filter(exports, func(s models.Export) bool {
return s.Slug == slug
}), nil
}
// Log retrieves (and prunes) all entries in the snapshot log.
func (log *Logger) Log() ([]models.Export, error) {
// query the table!
table, err := log.SQL.QueryTable(false, models.ExportTable)
if err != nil {
return nil, err
}
// find all the exports
var exports []models.Export
res := table.Find(&exports)
if res.Error != nil {
return nil, res.Error
}
// partition out the exports that have been deleted!
parts := collection.Partition(exports, func(s models.Export) bool {
_, err := log.Still.Environment.Stat(s.Path)
return !environment.IsNotExist(err)
})
// go and delete them!
if len(parts[false]) > 0 {
if err := table.Delete(parts[false]).Error; err != nil {
return nil, err
}
}
// return the ones that still exist
return parts[true], nil
}
// AddToExportLog adds the provided export to the log.
func (log *Logger) Add(export models.Export) error {
// find the table
table, err := log.SQL.QueryTable(false, models.ExportTable)
if err != nil {
return err
}
// and save it!
res := table.Create(&export)
if res.Error != nil {
return res.Error
}
return nil
}

View file

@ -0,0 +1,33 @@
package exporter
import (
"path/filepath"
)
type WithManifest struct {
Manifest []string
}
func (wm *WithManifest) handleManifest(dest string) (chan<- string, func()) {
manifest := make(chan string)
done := make(chan struct{})
go func() {
defer close(done)
for file := range manifest {
// get the relative path to the root of the manifest.
// nothing *should* go wrong, but in case it does, use the original path.
path, err := filepath.Rel(dest, file)
if err != nil {
path = file
}
// add the manifest
wm.Manifest = append(wm.Manifest, path)
}
}()
return manifest, func() {
close(manifest)
<-done
}
}

View file

@ -0,0 +1,55 @@
package exporter
import (
"path/filepath"
"time"
"github.com/tkw1536/goprogram/stream"
)
// ShouldPrune determines if a file with the provided modification time should be
// removed from the export log.
func (exporter *Exporter) ShouldPrune(modtime time.Time) bool {
return time.Since(modtime) > time.Duration(exporter.Config.MaxBackupAge)*24*time.Hour
}
// Prune prunes all old exports
func (exporter *Exporter) PruneExports(io stream.IOStream) error {
sPath := exporter.ArchivePath()
// list all the files
entries, err := exporter.Still.Environment.ReadDir(sPath)
if err != nil {
return err
}
for _, entry := range entries {
// skip directories
if entry.IsDir() {
continue
}
// grab info about the file
info, err := entry.Info()
if err != nil {
return err
}
// check if it should be pruned!
if !exporter.ShouldPrune(info.ModTime()) {
continue
}
// assemble path, and then remove the file!
path := filepath.Join(sPath, entry.Name())
io.Printf("Removing %s cause it is older than %d days", path, exporter.Config.MaxBackupAge)
if err := exporter.Still.Environment.Remove(path); err != nil {
return err
}
}
// prune the snapshot log!
_, err = exporter.ExporterLogger.Log()
return err
}

View file

@ -0,0 +1,110 @@
package exporter
import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/FAU-CDI/wisski-distillery/pkg/countwriter"
)
func (snapshot Snapshot) String() string {
var builder strings.Builder
snapshot.Report(&builder)
return builder.String()
}
// Report writes a report from snapshot into w
func (snapshot Snapshot) Report(w io.Writer) (int, error) {
ww := countwriter.NewCountWriter(w)
encoder := json.NewEncoder(ww)
encoder.SetIndent("", " ")
io.WriteString(ww, "======= Begin Snapshot Report "+snapshot.Instance.Slug+" =======\n")
fmt.Fprintf(ww, "Slug: %s\n", snapshot.Instance.Slug)
fmt.Fprintf(ww, "Dest: %s\n", snapshot.Description.Dest)
fmt.Fprintf(ww, "Start: %s\n", snapshot.StartTime)
fmt.Fprintf(ww, "End: %s\n", snapshot.EndTime)
io.WriteString(ww, "\n")
io.WriteString(ww, "======= Description =======\n")
encoder.Encode(snapshot.Description)
io.WriteString(ww, "\n")
io.WriteString(ww, "======= Instance =======\n")
encoder.Encode(snapshot.Instance)
io.WriteString(ww, "\n")
io.WriteString(ww, "======= Errors =======\n")
fmt.Fprintf(ww, "Panic: %v\n", snapshot.ErrPanic)
fmt.Fprintf(ww, "Start: %s\n", snapshot.ErrStart)
fmt.Fprintf(ww, "Stop: %s\n", snapshot.ErrStop)
fmt.Fprintf(ww, "Whitebox: %s\n", snapshot.ErrWhitebox)
fmt.Fprintf(ww, "Blackbox: %s\n", snapshot.ErrBlackbox)
io.WriteString(ww, "\n")
io.WriteString(ww, "======= Manifest =======\n")
for _, file := range snapshot.Manifest {
io.WriteString(ww, file+"\n")
}
io.WriteString(ww, "\n")
io.WriteString(ww, "======= End Snapshot Report "+snapshot.Instance.Slug+"=======\n")
return ww.Sum()
}
// Strings turns this backup into a string for the BackupReport.
func (backup Backup) String() string {
var builder strings.Builder
backup.Report(&builder)
return builder.String()
}
// Report formats a report for this backup, and writes it into Writer.
func (backup Backup) Report(w io.Writer) (int, error) {
cw := countwriter.NewCountWriter(w)
encoder := json.NewEncoder(cw)
encoder.SetIndent("", " ")
io.WriteString(cw, "======= Backup =======\n")
fmt.Fprintf(cw, "Start: %s\n", backup.StartTime)
fmt.Fprintf(cw, "End: %s\n", backup.EndTime)
io.WriteString(cw, "\n")
io.WriteString(cw, "======= Description =======\n")
encoder.Encode(backup.Description)
io.WriteString(cw, "\n")
io.WriteString(cw, "======= Errors =======\n")
fmt.Fprintf(cw, "Panic: %v\n", backup.ErrPanic)
fmt.Fprintf(cw, "Component Errors: %v\n", backup.ComponentErrors)
fmt.Fprintf(cw, "ConfigFileErr: %s\n", backup.ConfigFileErr)
fmt.Fprintf(cw, "InstanceListErr: %s\n", backup.InstanceListErr)
io.WriteString(cw, "\n")
io.WriteString(cw, "======= Snapshots =======\n")
for _, s := range backup.InstanceSnapshots {
io.WriteString(cw, s.String())
io.WriteString(cw, "\n")
}
io.WriteString(cw, "======= Manifest =======\n")
for _, file := range backup.Manifest {
io.WriteString(cw, file+"\n")
}
io.WriteString(cw, "\n")
return cw.Sum()
}

View file

@ -0,0 +1,139 @@
package exporter
import (
"fmt"
"io"
"path/filepath"
"time"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
"github.com/FAU-CDI/wisski-distillery/pkg/logging"
"github.com/tkw1536/goprogram/lib/collection"
"github.com/tkw1536/goprogram/status"
"github.com/tkw1536/goprogram/stream"
"golang.org/x/exp/slices"
)
// SnapshotDescription is a description for a snapshot
type SnapshotDescription struct {
Dest string // destination path
Keepalive bool // should we keep the instance alive while making the snapshot?
}
// Snapshot represents the result of generating a snapshot
type Snapshot struct {
Description SnapshotDescription
Instance models.Instance
// Start and End Time of the snapshot
StartTime time.Time
EndTime time.Time
// Generic Panic that may have occured
ErrPanic interface{}
ErrStart error
ErrStop error
ErrWhitebox map[string]error
ErrBlackbox map[string]error
// List of files included
WithManifest
}
// Snapshot creates a new snapshot of this instance into dest
func (snapshots *Exporter) NewSnapshot(instance *wisski.WissKI, io stream.IOStream, desc SnapshotDescription) (snapshot Snapshot) {
logging.LogMessage(io, "Locking instance")
if err := instance.TryLock(); err != nil {
io.EPrintln(err)
logging.LogMessage(io, "Aborting snapshot creation")
return Snapshot{
ErrPanic: err,
}
}
defer func() {
logging.LogMessage(io, "Unlocking instance")
instance.Unlock()
}()
// setup the snapshot
snapshot.Description = desc
snapshot.Instance = instance.Instance
// capture anything critical, and write the end time
defer func() {
snapshot.ErrPanic = recover()
}()
// do the create keeping track of time!
logging.LogOperation(func() error {
snapshot.StartTime = time.Now().UTC()
snapshot.ErrWhitebox = snapshot.makeParts(io, snapshots, instance, false)
snapshot.ErrBlackbox = snapshot.makeParts(io, snapshots, instance, true)
snapshot.EndTime = time.Now().UTC()
return nil
}, io, "Writing snapshot files")
slices.Sort(snapshot.Manifest)
return
}
func (snapshot *Snapshot) makeParts(ios stream.IOStream, snapshots *Exporter, instance *wisski.WissKI, needsRunning bool) map[string]error {
if !needsRunning && !snapshot.Description.Keepalive {
stack := instance.Barrel()
logging.LogMessage(ios, "Stopping instance")
snapshot.ErrStop = stack.Down(ios)
defer func() {
logging.LogMessage(ios, "Starting instance")
snapshot.ErrStart = stack.Up(ios)
}()
}
// handle writing the manifest!
manifest, done := snapshot.handleManifest(snapshot.Description.Dest)
defer done()
// create a new status
st := status.NewWithCompat(ios.Stdout, 0)
st.Start()
defer st.Stop()
// get all the components
comps := collection.FilterClone(snapshots.Snapshotable, func(sc component.Snapshotable) bool {
return sc.SnapshotNeedsRunning() == needsRunning
})
results := make(map[string]error, len(comps))
errors := status.Group[component.Snapshotable, error]{
PrefixString: func(item component.Snapshotable, index int) string {
return fmt.Sprintf("[snapshot %q]: ", item.Name())
},
PrefixAlign: true,
Handler: func(sc component.Snapshotable, index int, writer io.Writer) error {
return sc.Snapshot(
instance.Instance,
component.NewStagingContext(
snapshots.Environment,
stream.NewIOStream(writer, writer, nil, 0),
filepath.Join(snapshot.Description.Dest, sc.SnapshotName()),
manifest,
),
)
},
ResultString: status.DefaultErrorString[component.Snapshotable],
}.Use(st, comps)
for i, wc := range comps {
results[wc.Name()] = errors[i]
}
return results
}

View file

@ -0,0 +1,70 @@
package home
import (
"context"
"fmt"
"net/http"
"time"
"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/lazy"
"github.com/tkw1536/goprogram/stream"
)
type Home struct {
component.Base
Instances *instances.Instances
RefreshInterval time.Duration
redirect lazy.Lazy[*Redirect]
instanceNames lazy.Lazy[map[string]struct{}]
homeBytes lazy.Lazy[[]byte]
}
func (*Home) Routes() []string { return []string{"/"} }
func (home *Home) Handler(route string, context context.Context, io stream.IOStream) (http.Handler, error) {
home.updateRedirect(context, io)
home.updateInstances(context, io)
home.updateRender(context, io)
return home, nil
}
func (home *Home) ServeHTTP(w http.ResponseWriter, r *http.Request) {
slug, ok := home.Config.SlugFromHost(r.Host)
switch {
case !ok:
http.NotFound(w, r)
case slug != "":
home.serveWissKI(w, slug, r)
default:
home.serveRoot(w, r)
}
}
func (home *Home) serveRoot(w http.ResponseWriter, r *http.Request) {
// not the root url => server the fallback
if !(r.URL.Path == "" || r.URL.Path == "/") {
home.redirect.Get(nil).ServeHTTP(w, r)
return
}
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusAccepted)
w.Write(home.homeBytes.Get(nil))
}
func (home *Home) serveWissKI(w http.ResponseWriter, slug string, r *http.Request) {
if _, ok := home.instanceNames.Get(nil)[slug]; !ok {
// Get(nil) guaranteed to work by precondition
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "WissKI %q not found\n", slug)
return
}
w.WriteHeader(http.StatusBadGateway)
fmt.Fprintf(w, "WissKI %q is currently offline\n", slug)
}

View file

@ -0,0 +1,40 @@
<!DOCTYPE html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WissKI Distillery</title>
{{ CSS }}
</head>
<body>
<header>
<h1>WissKI Distillery</h1>
</header>
<main>
<div class="pure-g">
<div class="pure-u-1">
<p>
For more information, see <a href="{{ .SelfRedirect }}">{{ .SelfRedirect }}</a>.
</p>
</div>
<div class="pure-u-1">
<h2>WissKIs on this Distillery</h2>
</div>
{{range .Instances}}
{{ if .Running }}
<div class="pure-u-1-3">
<h3>{{.Slug}}</h3>
<p>
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.URL}}</a><br>
</p>
</div>
{{ end }}
{{ end }}
</main>
<footer>
Generated at <code>{{ .Time }}</code>
</footer>
{{ JS }}
</body>

View file

@ -0,0 +1,94 @@
package home
import (
"bytes"
"context"
"time"
_ "embed"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/static"
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
"github.com/FAU-CDI/wisski-distillery/pkg/timex"
"github.com/tkw1536/goprogram/stream"
"golang.org/x/sync/errgroup"
)
func (home *Home) updateInstances(ctx context.Context, io stream.IOStream) {
go func() {
for t := range timex.TickContext(ctx, home.RefreshInterval) {
io.Printf("[%s]: reloading instance list\n", t.Format(time.Stamp))
names, _ := home.instanceMap()
home.instanceNames.Set(names)
}
}()
}
func (home *Home) instanceMap() (map[string]struct{}, error) {
wissKIs, err := home.Instances.All()
if err != nil {
return nil, err
}
names := make(map[string]struct{}, len(wissKIs))
for _, w := range wissKIs {
names[w.Slug] = struct{}{}
}
return names, nil
}
func (home *Home) updateRender(ctx context.Context, io stream.IOStream) {
go func() {
for t := range timex.TickContext(ctx, home.RefreshInterval) {
io.Printf("[%s]: reloading home render\n", t.Format(time.Stamp))
bytes, _ := home.homeRender()
home.homeBytes.Set(bytes)
}
}()
}
//go:embed "home.html"
var homeHTMLStr string
var homeTemplate = static.AssetsHomeHome.MustParse(homeHTMLStr)
func (home *Home) homeRender() ([]byte, error) {
var context HomeContext
// setup a couple of static things
context.Time = time.Now().UTC()
context.SelfRedirect = home.Config.SelfRedirect.String()
// find all the WissKIs
wissKIs, err := home.Instances.All()
if err != nil {
return nil, err
}
context.Instances = make([]wisski.WissKIInfo, len(wissKIs))
// determine their infos
var eg errgroup.Group
for i, instance := range wissKIs {
i := i
wissKI := instance
eg.Go(func() (err error) {
context.Instances[i], err = wissKI.Info(true)
return
})
}
eg.Wait()
// render the template
var buffer bytes.Buffer
homeTemplate.Execute(&buffer, context)
return buffer.Bytes(), nil
}
type HomeContext struct {
Instances []wisski.WissKIInfo
Time time.Time
SelfRedirect string
}

View file

@ -0,0 +1,117 @@
package home
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/FAU-CDI/wisski-distillery/pkg/timex"
"github.com/tkw1536/goprogram/stream"
)
func (home *Home) updateRedirect(ctx context.Context, io stream.IOStream) {
go func() {
for t := range timex.TickContext(ctx, home.RefreshInterval) {
io.Printf("[%s]: reloading overrides\n", t.Format(time.Stamp))
redirect, _ := home.loadRedirect()
home.redirect.Set(&redirect)
}
}()
}
func (home *Home) loadRedirect() (redirect Redirect, err error) {
if redirect.Overrides == nil {
redirect.Overrides = make(map[string]string)
}
redirect.Overrides[""] = home.Config.SelfRedirect.String()
redirect.Absolute = false
redirect.Permanent = false
// load the overrides file
overrides, err := home.Environment.Open(home.Config.SelfOverridesFile)
if err != nil {
return redirect, err
}
defer overrides.Close()
// decode the overrides file
if err := json.NewDecoder(overrides).Decode(&redirect.Overrides); err != nil {
return redirect, err
}
// and return!
return redirect, nil
}
// Redirect implements a redirect server that redirects all requests.
// It implements http.Handler.
type Redirect struct {
// Target is the target URL to redirect to.
Target string
// Fallback is used when target is the empty string.
Fallback http.Handler
// Absolute determines if the request path should be appended to the target URL when redirecting.
// By default this path is always appended, set Absolute to true to prevent this.
Absolute bool
// Overrides is a map from paths to URLs that should override the default target.
Overrides map[string]string
// Permanent determines if the redirect responses issued should return
// Permanent Redirect (Status Code 308) or Temporary Redirect (Status Code 307).
Permanent bool
}
// Redirect determines the redirect URL for a specific incoming request
// If it returns the empty string, the fallback is used.
func (redirect Redirect) Redirect(r *http.Request) string {
// if we have an override for this URL, use it immediatly
url := strings.TrimSuffix(r.URL.Path, "/")
if override, ok := redirect.Overrides[url]; ok {
return override
}
if redirect.Target == "" {
return ""
}
// if we are in absolute redirect mode, always return the absolute URL
if redirect.Absolute {
return redirect.Target
}
// return the target + the redirected URL
dest := strings.TrimSuffix(redirect.Target, "/") + r.URL.Path
if len(r.URL.RawQuery) > 0 {
dest += "?" + r.URL.RawQuery
}
return dest
}
// ServeHTTP implements the http.Handler interface and redirects a single request to redirect.Target.
func (redirect Redirect) ServeHTTP(w http.ResponseWriter, r *http.Request) {
dest := redirect.Redirect(r)
if dest == "" {
if redirect.Fallback == nil {
http.NotFound(w, r)
return
}
redirect.Fallback.ServeHTTP(w, r)
return
}
// determine if we are temporary or permanent redirect
status := http.StatusTemporaryRedirect
if redirect.Permanent {
status = http.StatusPermanentRedirect
}
// and do the redirect
http.Redirect(w, r, dest, status)
}

View file

@ -0,0 +1,291 @@
<!DOCTYPE html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Distillery Control Page</title>
{{ CSS }}
</head>
<body>
<header>
<h1 id="top">Distillery Control Page</h1>
<small>Generated at <code class="date">{{ .Time.Format "2006-01-02T15:04:05Z07:00" }}</code></small>
<p>
<a class="pure-button pure-button-primary" href="/dis/index">Control</a>
</p>
</header>
<main>
<div class="pure-g">
<div class="pure-u-1-1">
<h2 id="overview">Distillery Configuration</h2>
</div>
<div class="pure-u-1 pure-u-xl-1-3">
<div class="padding">
<div class="overflow">
<table class="pure-table pure-table-bordered">
<thead>
<tr>
<th colspan="2">
Domains
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
Primary
</td>
<td>
<code>{{.Config.DefaultDomain}}</code>
</td>
</tr>
<tr>
<td>
Extra
</td>
<td>
{{ range .Config.SelfExtraDomains }}
<code>{{.}}</code><br />
{{ end }}
</td>
</tr>
<tr>
<td>
Email <small>(HTTPS)</small>
</td>
<td>
<code>{{.Config.CertbotEmail}}</code>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="pure-u-1 pure-u-xl-1-3">
<div class="padding">
<div class="overflow">
<table class="pure-table pure-table-bordered">
<thead>
<tr>
<th colspan="2">
Database Settings
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
MySQL User Prefix
</td>
<td>
<code>{{.Config.MysqlUserPrefix}}</code>
</td>
</tr>
<tr>
<td>
MySQL Database Prefix
</td>
<td>
<code>{{.Config.MysqlDatabasePrefix}}</code>
</td>
</tr>
<tr>
<td>
GraphDB User Prefix
</td>
<td>
<code>{{.Config.GraphDBUserPrefix}}</code>
</td>
</tr>
<tr>
<td>
GraphDB Database Prefix
</td>
<td>
<code>{{.Config.GraphDBRepoPrefix}}</code>
</td>
</tr>
<tr>
<td>
Bookkeeping Database
</td>
<td>
<code>{{.Config.DistilleryDatabase}}</code>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="pure-u-1 pure-u-xl-1-3">
<div class="padding">
<div class="overflow">
<table class="pure-table pure-table-bordered">
<thead>
<tr>
<th colspan="2">
Directory Settings
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>root</code>
</td>
<td>
<code>{{.Config.DeployRoot}}</code>
</td>
</tr>
<tr>
<td>
<code>config</code>
</td>
<td>
<code>{{.Config.ConfigPath}}</code>
</td>
</tr>
<tr>
<td>
<code>authorized_keys</code>
</td>
<td>
<code>{{.Config.GlobalAuthorizedKeysFile}}</code>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="pure-u-1 pure-u-xl-2-5">
<div class="padding">
<div class="overflow">
<table class="pure-table pure-table-bordered">
<thead>
<tr>
<th colspan="2">
Misc Settings
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
Homepage
</td>
<td>
<a href="{{.Config.SelfRedirect}}" target="_blank" rel="noopener noreferrer">{{.Config.SelfRedirect}}</a>
</td>
</tr>
<tr>
<td>
Docker Network Name
</td>
<td>
<code>{{.Config.DockerNetworkName}}</code>
</td>
</tr>
<tr>
<td>
Backup Age
</td>
<td>
<code>{{.Config.MaxBackupAge}}</code> Day(s)
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="pure-u-1-1">
<h2 id="backups">Backups</h2>
</div>
<div class="pure-u-1">
<table class="pure-table pure-table-bordered padding">
<thead>
<tr>
<th>Path</th>
<th>Created</th>
<th>Packed</th>
</tr>
</thead>
<tbody>
{{ range .Backups }}
<tr>
<td>
<code class="path">{{ .Path }}</code>
</td>
<td>
<code class="date">{{ .Created.Format "2006-01-02T15:04:05Z07:00" }}</code>
</td>
<td>
{{ .Packed }}
</td>
</tr>
{{ end}}
</tbody>
</table>
</div>
<div class="pure-u-1">
<h2 id="instances">Instances</h2>
<table class="pure-table pure-table-bordered padding">
<thead>
<tr>
<th>Total</th>
<th>Running</th>
<th>Stopped</th>
</tr>
</thead>
<tbody>
<tr>
<td>
{{ .TotalCount }}
</td>
<td>
{{ .RunningCount }}
</td>
<td>
{{ .StoppedCount }}
</td>
</tr>
</tbody>
</table>
<span class="hspace"></span>
</div>
{{range .Instances}}
<div class="pure-u-1 pure-u-xl-1-3">
<div class="wisski {{ if .Running }}running{{ else }}stopped{{ end }}">
<h3>
{{.Slug}}
{{ if not .Running }}&nbsp;<small>not running</small>{{ end }}
</h3>
<p>
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.URL}}</a><br>
<a class="pure-button" href="/dis/instance/{{.Slug}}">Details</a>
</p>
</div>
</div>
{{end}}
</div>
</main>
<footer>
Generated at <code>{{ .Time }}</code>
</footer>
{{ JS }}
</body>

View file

@ -0,0 +1,312 @@
<!DOCTYPE html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Distillery Control Page - {{ .Info.Slug }}</title>
{{ CSS }}
</head>
<body>
<header>
<h1 id="top">Distillery Control Page - {{ .Info.Slug }}</h1>
<small>Generated at <code class="date">{{ .Time.Format "2006-01-02T15:04:05Z07:00" }}</code></small>
<p>
<a class="pure-button" href="/dis/index">Control</a> &gt;
<a class="pure-button pure-button-primary" href="/dis/instance/{{ .Info.Slug }}">Instance</a>
</p>
</header>
<main>
<div class="pure-g">
<div class="pure-u-1-1">
<h2 id="overview">Info &amp; Status</h2>
</div>
<div class="pure-u-1 pure-u-xl-2-5">
<div class="padding">
<div class="overflow">
<table class="pure-table pure-table-bordered">
<thead>
<tr>
<th colspan="2">
Overview
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
Slug
</td>
<td>
<code>{{ .Info.Slug }}</code>
</td>
</tr>
<tr>
<td>
URL
</td>
<td>
<a href="{{ .Info.URL }}" target="_blank" rel="noopener noreferrer">{{ .Info.URL }}</a>
</td>
</tr>
<tr>
<td>
Running
</td>
<td>
<code>{{ .Info.Running }}</code>
</td>
</tr>
<tr>
<td>
Locked
</td>
<td>
<code>{{ .Info.Locked }}</code>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="pure-u-1 pure-u-xl-2-5">
<div class="padding">
<div class="overflow">
<table class="pure-table pure-table-bordered">
<thead>
<tr>
<th colspan="2">
Component Settings
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
Directory
</td>
<td>
<code style="overflow: auto;">{{ .Instance.FilesystemBase }}</code>
</td>
</tr>
<tr>
<td>
SQL DB
</td>
<td>
<code>{{ .Instance.SqlDatabase }}</code>
</td>
</tr>
<tr>
<td>
SQL User
</td>
<td>
<code>{{ .Instance.SqlUsername }}</code>
</td>
</tr>
<tr>
<td>
TS Repo
</td>
<td>
<code>{{ .Instance.GraphDBRepository }}</code>
</td>
</tr>
<tr>
<td>
TS User
</td>
<td>
<code>{{ .Instance.GraphDBUsername }}</code>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="pure-u-1 pure-u-xl-2-5">
<div class="padding">
<div class="overflow">
<table class="pure-table pure-table-bordered">
<thead>
<tr>
<th colspan="2">
Build Status
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
Created
</td>
<td>
<code class="date">{{ .Instance.Created.Format "2006-01-02T15:04:05Z07:00" }}</code>
</td>
</tr>
<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>
</td>
<td>
<code class="date">{{ .Info.LastRebuild.Format "2006-01-02T15:04:05Z07:00" }}</code>
</td>
</tr>
<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>
</td>
<td>
<code class="date">{{ .Info.LastRebuild.Format "2006-01-02T15:04:05Z07:00" }}</code>
</td>
</tr>
<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>
</td>
<td>
<code class="date">{{ .Info.LastUpdate.Format "2006-01-02T15:04:05Z07:00" }}</code><br>
(Automatic: <code>{{ .Instance.AutoBlindUpdateEnabled }}</code>)
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="pure-u-1 pure-u-xl-2-5">
<!--
<div class="padding">
<div class="overflow">
<table class="pure-table pure-table-bordered">
<thead>
<tr>
<th colspan="2">
Composer Status
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
???
</td>
<td>
???
</td>
</tr>
</tbody>
</table>
</div>
</div>
-->
</div>
<div class="pure-u-1-1">
<h2 id="wisski">WissKI Data</h2>
</div>
<div class="pure-u-1 pure-u-xl-1-2">
<div class="padding">
<div class="overflow">
<table class="pure-table pure-table-bordered">
<thead>
<tr>
<th colspan="2">
Pathbuilders
</th>
</tr>
</thead>
<tbody>
{{ range $name, $xml := .Info.Pathbuilders }}
<tr>
<td>
<code>{{ $name }}</code>
</td>
<td>
<code class="pathbuilder" data-name="{{ $name }}">{{ $xml }}</code>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
<div class="pure-u-1 pure-u-xl-1-2">
<div class="padding">
<div class="overflow">
<table class="pure-table pure-table-bordered">
<thead>
<tr>
<th>
URI Prefixes
{{ if .Info.NoPrefixes }}
(excluded from resolver)
{{ end }}
</th>
</tr>
</thead>
<tbody>
{{ range $index, $prefix := .Info.Prefixes }}
<tr>
<td>
<code>{{ $prefix }}</code>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
<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>
</p>
</div>
<div class="pure-u-1">
<table class="pure-table pure-table-bordered padding">
<thead>
<tr>
<th>Path</th>
<th>Created</th>
<th>Packed</th>
</tr>
</thead>
<tbody>
{{ range .Info.Snapshots }}
<tr>
<td>
<code class="path">{{ .Path }}</code>
</td>
<td>
<code class="date">{{ .Created.Format "2006-01-02T15:04:05Z07:00" }}</code>
</td>
<td>
{{ .Packed }}
</td>
</tr>
{{ end}}
</tbody>
</table>
</div>
</div>
</div>
</main>
{{ JS }}
</body>

View file

@ -0,0 +1,85 @@
package info
import (
"net/http"
"time"
_ "embed"
"github.com/FAU-CDI/wisski-distillery/internal/config"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/static"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
"golang.org/x/sync/errgroup"
)
//go:embed "html/index.html"
var indexTemplateStr string
var indexTemplate = static.AssetsControlIndex.MustParse(indexTemplateStr)
type indexPageContext struct {
Time time.Time
Config *config.Config
Instances []wisski.WissKIInfo
TotalCount int
RunningCount int
StoppedCount int
Backups []models.Export
}
func (info *Info) indexPageAPI(r *http.Request) (idx indexPageContext, err error) {
var group errgroup.Group
group.Go(func() error {
// list all the instances
all, err := info.Instances.All()
if err != nil {
return err
}
// get all of their info!
idx.Instances = make([]wisski.WissKIInfo, len(all))
for i, instance := range all {
{
i := i
instance := instance
// store the info for this group!
group.Go(func() (err error) {
idx.Instances[i], err = instance.Info(true)
return err
})
}
}
return nil
})
// get the log entries
group.Go(func() (err error) {
idx.Backups, err = info.SnapshotsLog.For("")
return
})
// get the static properties
idx.Config = info.Config
idx.Time = time.Now().UTC()
group.Wait()
// count how many are running and how many are stopped
for _, i := range idx.Instances {
if i.Running {
idx.RunningCount++
} else {
idx.StoppedCount++
}
}
idx.TotalCount = len(idx.Instances)
return
}

View file

@ -0,0 +1,60 @@
package info
import (
"context"
"net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"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"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
"github.com/tkw1536/goprogram/stream"
)
type Info struct {
component.Base
Exporter *exporter.Exporter
Instances *instances.Instances
SnapshotsLog *logger.Logger
}
func (*Info) Routes() []string { return []string{"/dis/"} }
func (info *Info) Handler(route string, context context.Context, io stream.IOStream) (http.Handler, error) {
mux := http.NewServeMux()
// handle everything
mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == route {
http.Redirect(w, r, route+"/index", http.StatusTemporaryRedirect)
return
}
http.NotFound(w, r)
})
// add a handler for the index page
mux.Handle(route+"index", httpx.HTMLHandler[indexPageContext]{
Handler: info.indexPageAPI,
Template: indexTemplate,
})
// add a handler for the instance page
mux.Handle(route+"instance/", httpx.HTMLHandler[instancePageContext]{
Handler: info.instancePageAPI,
Template: instanceTemplate,
})
handler := &httpx.WebSocket{
Context: context,
Fallback: mux,
Handler: info.serveSocket,
}
// ensure that everyone is logged in!
return httpx.BasicAuth(handler, "WissKI Distillery Admin", func(user, pass string) bool {
return user == info.Config.DisAdminUser && pass == info.Config.DisAdminPassword
}), nil
}

View file

@ -0,0 +1,52 @@
package info
import (
_ "embed"
"net/http"
"strings"
"time"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/static"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
)
//go:embed "html/instance.html"
var instanceTemplateString string
var instanceTemplate = static.AssetsControlInstance.MustParse(instanceTemplateString)
type instancePageContext struct {
Time time.Time
Instance models.Instance
Info wisski.WissKIInfo
}
func (info *Info) instancePageAPI(r *http.Request) (is instancePageContext, err error) {
// find the slug as the last component of path!
slug := strings.TrimSuffix(r.URL.Path, "/")
slug = slug[strings.LastIndex(slug, "/")+1:]
// find the instance itself!
instance, err := info.Instances.WissKI(slug)
if err == instances.ErrWissKINotFound {
return is, httpx.ErrNotFound
}
if err != nil {
return is, err
}
is.Instance = instance.Instance
// get some more info about the wisski
is.Info, err = instance.Info(false)
if err != nil {
return is, err
}
// current time
is.Time = time.Now().UTC()
return
}

View file

@ -0,0 +1,86 @@
package info
import (
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
"github.com/tkw1536/goprogram/status"
"github.com/tkw1536/goprogram/stream"
)
type instanceActionFunc = func(info *Info, instance *wisski.WissKI, str stream.IOStream) error
var socketInstanceActions = map[string]instanceActionFunc{
"snapshot": func(info *Info, instance *wisski.WissKI, str stream.IOStream) error {
return info.Exporter.MakeExport(
str,
exporter.ExportTask{
Dest: "",
Instance: instance,
StagingOnly: false,
},
)
},
"rebuild": func(_ *Info, instance *wisski.WissKI, str stream.IOStream) error {
return instance.Build(str, true)
},
"update": func(_ *Info, instance *wisski.WissKI, str stream.IOStream) error {
return instance.BlindUpdate(str)
},
"cron": func(_ *Info, instance *wisski.WissKI, str stream.IOStream) error {
return instance.Cron(str)
},
}
func (info *Info) serveSocket(conn httpx.WebSocketConnection) {
// read the next message to act on
message, ok := <-conn.Read()
if !ok {
return
}
// perform an action if it exists!
if action, ok := socketInstanceActions[string(message.Bytes)]; ok {
info.handleInstanceAction(conn, action)
return
}
}
func (info *Info) handleInstanceAction(conn httpx.WebSocketConnection, action instanceActionFunc) {
// read the slug
slug, ok := <-conn.Read()
if !ok {
conn.WriteText("Error reading slug")
return
}
// resolve the instance
instance, err := info.Instances.WissKI(string(slug.Bytes))
if err != nil {
conn.WriteText("Instance not found")
return
}
// build a stream
writer := &status.LineBuffer{
Line: func(line string) {
<-conn.WriteText(line)
},
FlushLineOnClose: true,
}
defer writer.Close()
str := stream.NewIOStream(writer, writer, nil, 0)
// and perform the action
{
err := action(info, instance, str)
if err != nil {
str.EPrintln(err)
return
}
str.Println("done")
}
}

View file

@ -0,0 +1,44 @@
package component
import (
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
"github.com/tkw1536/goprogram/stream"
)
// Installable implements an installable component.
type Installable interface {
Component
// Path returns the path this component is installed at.
// By convention it is /var/www/deploy/internal/core/${Name()}
Path() string
// Stack can be used to gain access to the "docker compose" stack.
//
// This should internally call [ComponentBase.MakeStack]
Stack(env environment.Environment) StackWithResources
// Context returns a new InstallationContext to be used during installation from the command line.
// Typically this should just pass through the parent, but might perform other tasks.
Context(parent InstallationContext) InstallationContext
}
// MakeStack registers the Installable as a stack
func MakeStack(component Installable, env environment.Environment, stack StackWithResources) StackWithResources {
stack.Env = env
stack.Dir = component.Path()
return stack
}
// Updatable represents a component with an Update method.
type Updatable interface {
Component
// Update updates or initializes the provided components.
// It is called after the component has been installed (if applicable).
//
// It may send output to the provided stream.
//
// Updating should be idempotent, meaning running it multiple times must not break the existing system.
Update(stream stream.IOStream) error
}

View file

@ -0,0 +1,65 @@
package instances
import (
"errors"
"path/filepath"
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
"github.com/FAU-CDI/wisski-distillery/pkg/stringparser"
)
var errInvalidSlug = errors.New("not a valid slug")
// Create fills the struct for a new WissKI instance.
// It validates that slug is a valid name for an instance.
//
// It does not perform any checks if the instance already exists, or does the creation in the database.
func (instances *Instances) Create(slug string) (wissKI *wisski.WissKI, err error) {
// make sure that the slug is valid!
slug, err = stringparser.ParseSlug(instances.Environment, slug)
if err != nil {
return nil, errInvalidSlug
}
wissKI = new(wisski.WissKI)
instances.use(wissKI)
wissKI.Instance.Slug = slug
wissKI.Instance.FilesystemBase = filepath.Join(instances.Path(), wissKI.Domain())
wissKI.Instance.OwnerEmail = ""
wissKI.Instance.AutoBlindUpdateEnabled = true
// sql
wissKI.Instance.SqlDatabase = instances.Config.MysqlDatabasePrefix + slug
wissKI.Instance.SqlUsername = instances.Config.MysqlUserPrefix + slug
wissKI.Instance.SqlPassword, err = instances.Config.NewPassword()
if err != nil {
return nil, err
}
// triplestore
wissKI.Instance.GraphDBRepository = instances.Config.GraphDBRepoPrefix + slug
wissKI.Instance.GraphDBUsername = instances.Config.GraphDBUserPrefix + slug
wissKI.Instance.GraphDBPassword, err = instances.Config.NewPassword()
if err != nil {
return nil, err
}
// drupal
wissKI.DrupalUsername = "admin" // TODO: Change this!
wissKI.DrupalPassword, err = instances.Config.NewPassword()
if err != nil {
return nil, err
}
// store the instance in the object and return it!
return wissKI, nil
}

View file

@ -0,0 +1,174 @@
package instances
import (
"errors"
"path/filepath"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter/logger"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/meta"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/triplestore"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
"github.com/tkw1536/goprogram/exit"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Instances manages multiple WissKI Instances.
type Instances struct {
component.Base
TS *triplestore.Triplestore
SQL *sql.SQL
Meta *meta.Meta
ExporterLog *logger.Logger
}
func (instances *Instances) Path() string {
return filepath.Join(instances.Still.Config.DeployRoot, "instances")
}
// ErrWissKINotFound is returned when a WissKI is not found
var ErrWissKINotFound = errors.New("WissKI not found")
var errSQL = exit.Error{
Message: "Unknown SQL Error %s",
ExitCode: exit.ExitGeneric,
}
// use uses the non-nil wisski instance with this instances
func (instances *Instances) use(wisski *wisski.WissKI) {
wisski.Core = instances.Still
wisski.SQL = instances.SQL
wisski.TS = instances.TS
wisski.Meta = instances.Meta
wisski.ExporterLog = instances.ExporterLog
}
// WissKI returns the WissKI with the provided slug, if it exists.
// It the WissKI does not exist, returns ErrWissKINotFound.
func (instances *Instances) WissKI(slug string) (wissKI *wisski.WissKI, err error) {
sql := instances.SQL
if err := sql.WaitQueryTable(); err != nil {
return nil, err
}
table, err := sql.QueryTable(false, models.InstanceTable)
if err != nil {
return nil, err
}
// create a struct
wissKI = new(wisski.WissKI)
// find the instance by slug
query := table.Where(&models.Instance{Slug: slug}).Find(&wissKI.Instance)
switch {
case query.Error != nil:
return nil, errSQL.WithMessageF(query.Error)
case query.RowsAffected == 0:
return nil, ErrWissKINotFound
}
// use the wissKI instance
instances.use(wissKI)
return wissKI, nil
}
// Instance is a convenience function to return an instance based on a model slug.
// When the instance does not exist, returns nil.
func (instances *Instances) Instance(instance models.Instance) *wisski.WissKI {
wissKI, err := instances.WissKI(instance.Slug)
if err != nil {
return nil
}
return wissKI
}
// Has checks if a WissKI with the provided slug exists inside the database.
// It does not perform any checks on the WissKI itself.
func (instances *Instances) Has(slug string) (ok bool, err error) {
sql := instances.SQL
if err := sql.WaitQueryTable(); err != nil {
return false, err
}
table, err := sql.QueryTable(false, models.InstanceTable)
if err != nil {
return false, err
}
query := table.Select("count(*) > 0").Where("slug = ?", slug).Find(&ok)
if query.Error != nil {
return false, errSQL.WithMessageF(query.Error)
}
return
}
// All returns all instances of the WissKI Distillery in consistent order.
//
// There is no guarantee that this order remains identical between different api releases; however subsequent invocations are guaranteed to return the same order.
func (instances *Instances) All() ([]*wisski.WissKI, error) {
return instances.find(true, func(table *gorm.DB) *gorm.DB {
return table
})
}
// WissKIs returns the WissKI instances with the provides slugs.
// If a slug does not exist, it is omitted from the result.
func (instances *Instances) WissKIs(slugs ...string) ([]*wisski.WissKI, error) {
return instances.find(true, func(table *gorm.DB) *gorm.DB {
return table.Where("slug IN ?", slugs)
})
}
// Load is like All, except that when no slugs are provided, it calls All.
func (instances *Instances) Load(slugs ...string) ([]*wisski.WissKI, error) {
if len(slugs) == 0 {
return instances.All()
}
return instances.WissKIs(slugs...)
}
// find finds instances based on the provided query
func (instances *Instances) find(order bool, query func(table *gorm.DB) *gorm.DB) (results []*wisski.WissKI, err error) {
sql := instances.SQL
if err := sql.WaitQueryTable(); err != nil {
return nil, err
}
// open the bookkeeping table
table, err := sql.QueryTable(false, models.InstanceTable)
if err != nil {
return nil, err
}
// prepare a query
find := table
if order {
find = find.Order(clause.OrderByColumn{Column: clause.Column{Name: "slug"}, Desc: false})
}
if query != nil {
find = query(find)
}
// fetch bookkeeping instances
var bks []models.Instance
find = find.Find(&bks)
if find.Error != nil {
return nil, errSQL.WithMessageF(find.Error)
}
// make proper instances
results = make([]*wisski.WissKI, len(bks))
for i, bk := range bks {
results[i] = new(wisski.WissKI)
results[i].Instance = bk
instances.use(results[i])
}
return results, nil
}

View file

@ -0,0 +1,30 @@
package instances
import (
"embed"
"github.com/FAU-CDI/wisski-distillery/pkg/unpack"
"github.com/tkw1536/goprogram/exit"
"github.com/tkw1536/goprogram/stream"
)
var errBootstrapFailedRuntime = exit.Error{
Message: "failed to update runtime",
ExitCode: exit.ExitGeneric,
}
// Runtime contains runtime resources to be installed into any instance
//
//go:embed all:runtime
var runtimeResources embed.FS
// Update installs or updates runtime components needed by this component.
func (instances *Instances) Update(stream stream.IOStream) error {
err := unpack.InstallDir(instances.Still.Environment, instances.Config.RuntimeDir(), "runtime", runtimeResources, func(dst, src string) {
stream.Printf("[copy] %s\n", dst)
})
if err != nil {
return errBootstrapFailedRuntime.Wrap(err)
}
return nil
}

View file

@ -0,0 +1,2 @@
Files in this folder are utility scripts to be used from within individual WissKI instances.
They are mounted under runtime/ and should be used with care.

View file

@ -0,0 +1,16 @@
#!/bin/bash
# This utility script can be used to blindly update all dependencies to their latest versions.
# It does not perform any checking whatsoever.
# update the main modules
cd /var/www/data/project || exit 1
chmod u+rw web/sites/default/
composer update
# update the db
drush -y updatedb
# update the wisski dependencies
cd /var/www/data/project/web/modules/contrib/wisski || exit 1
composer update

View file

@ -0,0 +1,25 @@
#!/bin/sh
set -e
# read user
USER=$1
if [ -z "$USER" ]; then
echo "Usage: create_admin.sh USERNAME"
exit 1
fi
# read password
echo "Enter Password for $USER:"
read -s PASS
echo "Enter the same password again:"
read -s PASS2
if [ "$PASS" != "$PASS2" ]; then
echo "Passwords not equal"
exit 1
fi;
# create the user and add the admin role
cd /var/www/data/project/
drush user:create "$USER" --password="$PASS"
drush user-add-role administrator "$USER"

View file

@ -0,0 +1,8 @@
#!/bin/bash
# This utility script can be used to run all cron tasks.
cd /var/www/data/project || exit 1
export PATH=/var/www/data/project/vendor/bin:$PATH
drush core-cron

View file

@ -0,0 +1,22 @@
#!/bin/bash
set -e
# make a temporary directory and cd into it
TEMPDIR=$(mktemp -d)
pushd "$TEMPDIR"
# curl the colorbox zip and unpack it
curl -L https://github.com/jackmoore/colorbox/archive/master.zip --output master.zip
unzip master.zip
# make the directory for libraries, and remove the old colorbox installation
chmod u+rw /var/www/data/project/web/sites/default/
mkdir -p /var/www/data/project/web/sites/default/libraries/
rm -rf /var/www/data/project/web/sites/default/libraries/colorbox
# copy over the new installation
mv colorbox-master/ /var/www/data/project/web/sites/default/libraries/colorbox
# cleanup
popd
rm -rf "$TEMPDIR"

View file

@ -0,0 +1,6 @@
#!/bin/sh
# This script can be used to repatch EasyRDF when needed.
cd /var/www/data/project/web/modules/contrib/wisski || exit 1
EASYRDF_RESPONSE="./vendor/easyrdf/easyrdf/lib/EasyRdf/Http/Response.php"
patch -N "$EASYRDF_RESPONSE" < "/patch/easyrdf.patch"

View file

@ -0,0 +1,6 @@
#!/bin/sh
# This script can be used to repatch EasyRDF when needed.
cd /var/www/data/project/web/modules/contrib/wisski/ || exit 1
TRIPLESTABCONTROLLER="./wisski_adapter_sparql11_pb/src/Controller/Sparql11TriplesTabController.php"
patch -N "$TRIPLESTABCONTROLLER" < "/patch/triples.patch"

View file

@ -0,0 +1,22 @@
#!/bin/bash
set -e
# read user
VERSION=$1
if [ -z "$VERSION" ]; then
echo "Usage: use_wisski.sh VERSION"
exit 1
fi
# update the main modules
cd /var/www/data/project
chmod u+rw web/sites/default/
composer require "drupal/wisski:$VERSION"
# update the wisski dependencies
pushd /var/www/data/project/web/modules/contrib/wisski
composer update
popd
# update the db
drush -y updatedb

View file

@ -0,0 +1,26 @@
#!/bin/bash
set -e
# temporarily extend permissions
chmod 777 web/sites/default
chmod 666 web/sites/default/*settings.php
chmod 666 web/sites/default/*services.yml
# update the core itself
composer require 'drupal/internal/core-recommended:^9' 'drupal/internal/core-composer-scaffold:^9' 'drupal/internal/core-project-message:^9' --update-with-dependencies --no-update
composer update
composer require 'drupal/wisski'
# update requirements for wisski!
pushd web/modules/contrib/wisski || exit 1
composer update
popd || exit 1
# run the update and clear the cache!
drush updatedb --yes
# drush cc
# and reset everything back to normal
chmod 755 web/sites/default
chmod 644 web/sites/default/*settings.php
chmod 644 web/sites/default/*services.yml

View file

@ -0,0 +1,42 @@
package meta
import (
"sync"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql"
)
// Component meta is responsible for managing metadata per WissKI Instance
type Meta struct {
component.Base
SQL *sql.SQL
sl sync.Mutex
sc map[string]*Storage
}
// Storage returns a Storage for the instance with the given slug.
// When slug is nil, returns a global storage.
func (meta *Meta) Storage(slug string) *Storage {
meta.sl.Lock()
defer meta.sl.Unlock()
// create the cache (unless it already exists)
if meta.sc == nil {
meta.sc = make(map[string]*Storage)
}
// cache hit
if storage, ok := meta.sc[slug]; ok {
return storage
}
// create a new storage
meta.sc[slug] = &Storage{
Slug: slug,
sql: meta.SQL,
}
return meta.sc[slug]
}

View file

@ -0,0 +1,14 @@
package meta
import "github.com/FAU-CDI/wisski-distillery/internal/models"
// Provision provisions new meta storage for this instance.
// NOTE(twiesing): This is a no-op, because we implement Purge.
func (meta *Meta) Provision(instance models.Instance, domain string) error {
return nil
}
// Purge purges the storage for the given instance.
func (meta *Meta) Purge(instance models.Instance, domain string) error {
return meta.Storage(instance.Slug).Purge()
}

View file

@ -0,0 +1,218 @@
package meta
import (
"encoding/json"
"errors"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/tkw1536/goprogram/lib/collection"
"gorm.io/gorm"
)
// Key represents a key for metadata.
type Key string
// ErrMetadatumNotSet is returned by various [MetaStorage] functions when a metadatum is not set
var ErrMetadatumNotSet = errors.New("metadatum not set")
// Storage manages metadata for either the entire distillery, or a single slug
type Storage struct {
Slug string
sql *sql.SQL
}
// Get retrieves metadata with the provided key and deserializes the first one into target.
// If no metadatum exists, returns [ErrMetadatumNotSet].
func (s Storage) Get(key Key, target any) error {
table, err := s.sql.QueryTable(true, models.MetadataTable)
if err != nil {
return err
}
// read the datum from the database
var datum models.Metadatum
status := table.Where(&models.Metadatum{Slug: s.Slug, Key: string(key)}).Order("pk DESC").Find(&datum)
// check if there was an error
if err := status.Error; err != nil {
return err
}
// check if e actually found it!
if status.RowsAffected == 0 {
return ErrMetadatumNotSet
}
// and do the unmarshaling!
return json.Unmarshal(datum.Value, target)
}
// GetAll receives all metadata with the provided keys.
// For each received value, the targets function is called with the current index, and total number of results.
// The function is intended to return a target for deserialization.
//
// When no metadatum exists, targets is not called, and nil error is returned.
func (s Storage) GetAll(key Key, target func(index, total int) any) error {
table, err := s.sql.QueryTable(true, models.MetadataTable)
if err != nil {
return err
}
// read the datum from the database
var data []models.Metadatum
status := table.Where(&models.Metadatum{Slug: s.Slug, Key: string(key)}).Find(&data)
// check if there was an error
if err := status.Error; err != nil {
return err
}
// unpack all of them into the destination
for index, datum := range data {
err := json.Unmarshal(datum.Value, target(index, len(data)))
if err != nil {
return err
}
}
return nil
}
// Delete deletes all metadata with the provided key.
func (s Storage) Delete(key Key) error {
table, err := s.sql.QueryTable(true, models.MetadataTable)
if err != nil {
return err
}
// delete all the values
status := table.Where(&models.Metadatum{Slug: s.Slug, Key: string(key)}).Delete(&models.Metadatum{})
if err := status.Error; err != nil {
return err
}
return nil
}
// Set serializes value and stores it with the provided key.
// Any other metadata with the same key is deleted.
func (s Storage) Set(key Key, value any) error {
table, err := s.sql.QueryTable(true, models.MetadataTable)
if err != nil {
return err
}
// marshal the value
bytes, err := json.Marshal(value)
if err != nil {
return err
}
return table.Transaction(func(tx *gorm.DB) error {
// delete the old values
status := tx.Where(&models.Metadatum{Slug: s.Slug, Key: string(key)}).Delete(&models.Metadatum{})
if err := status.Error; err != nil {
return err
}
// create the new item to insert
status = tx.Create(&models.Metadatum{
Key: string(key),
Slug: s.Slug,
Value: bytes,
})
if err := status.Error; err != nil {
return err
}
return nil
})
}
// Set serializes values and stores them with the provided key.
// Any other metadata with the same key is deleted.
func (s Storage) SetAll(key Key, values ...any) error {
table, err := s.sql.QueryTable(true, models.MetadataTable)
if err != nil {
return err
}
return table.Transaction(func(tx *gorm.DB) error {
// delete the old values
status := tx.Where(&models.Metadatum{Slug: s.Slug, Key: string(key)}).Delete(&models.Metadatum{})
if err := status.Error; err != nil {
return err
}
for _, value := range values {
bytes, err := json.Marshal(value)
if err != nil {
return err
}
// create the new item to insert
status := tx.Create(&models.Metadatum{
Key: string(key),
Slug: s.Slug,
Value: bytes,
})
if err := status.Error; err != nil {
return err
}
}
return nil
})
}
// Purge removes all metadata, regardless of key.
func (s Storage) Purge() error {
table, err := s.sql.QueryTable(true, models.MetadataTable)
if err != nil {
return err
}
status := table.Where("slug = ?", s.Slug).Delete(&models.Metadatum{})
if status.Error != nil {
return status.Error
}
return nil
}
// StorageFor returns a storage for the given key.
func StorageFor[Value any](key Key) func(storage *Storage) SpecifcStorage[Value] {
return func(storage *Storage) SpecifcStorage[Value] {
return SpecifcStorage[Value]{storage: storage, key: key}
}
}
type SpecifcStorage[Value any] struct {
storage *Storage
key Key
}
func (sf SpecifcStorage[Value]) Get() (value Value, err error) {
err = sf.storage.Get(sf.key, &value)
return
}
func (sf SpecifcStorage[Value]) GetAll() (values []Value, err error) {
err = sf.storage.GetAll(sf.key, func(index, total int) any {
if values == nil {
values = make([]Value, total)
}
return &values[index]
})
return values, err
}
func (sf SpecifcStorage[Value]) Set(value Value) error {
return sf.storage.Set(sf.key, value)
}
func (sf SpecifcStorage[Value]) SetAll(values ...Value) error {
return sf.storage.SetAll(sf.key, collection.AsAny(values)...)
}
func (sf SpecifcStorage[Value]) Delete() error {
return sf.storage.Delete(sf.key)
}

View file

@ -0,0 +1,18 @@
package component
import (
"github.com/FAU-CDI/wisski-distillery/internal/models"
)
// Provisionable is a component that can be provisioned
type Provisionable interface {
Component
// Provision provisions resources specific to the provided instance.
// Domain holds the full (unique) domain name of the given instance.
Provision(instance models.Instance, domain string) error
// Purge purges resources specific to the provided instance.
// Domain holds the full (unique) domain name of the given instance.
Purge(instance models.Instance, domain string) error
}

View file

@ -0,0 +1,52 @@
package resolver
import (
"context"
"time"
"github.com/FAU-CDI/wisski-distillery/pkg/timex"
"github.com/tkw1536/goprogram/stream"
)
// updatePrefixes starts updating prefixes
func (resolver *Resolver) updatePrefixes(io stream.IOStream, ctx context.Context) {
go func() {
for t := range timex.TickContext(ctx, resolver.RefreshInterval) {
io.Printf("[%s]: reloading prefixes\n", t.Format(time.Stamp))
prefixes, _ := resolver.AllPrefixes()
resolver.prefixes.Set(prefixes)
}
}()
}
// AllPrefixes returns a list of all prefixes from the server.
// Prefixes may be cached on the server
func (resolver *Resolver) AllPrefixes() (map[string]string, error) {
instances, err := resolver.Instances.All()
if err != nil {
return nil, err
}
gPrefixes := make(map[string]string)
var lastErr error
for _, instance := range instances {
if instance.NoPrefix() {
continue
}
url := instance.URL().String()
// failed to fetch prefixes for this particular instance
// => skip it!
prefixes, err := instance.PrefixesCached()
if err != nil {
lastErr = err
continue
}
for _, p := range prefixes {
gPrefixes[p] = url
}
}
return gPrefixes, lastErr
}

View file

@ -0,0 +1,72 @@
package resolver
import (
"context"
"fmt"
"net/http"
"regexp"
"time"
"github.com/FAU-CDI/wdresolve"
"github.com/FAU-CDI/wdresolve/resolvers"
"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/lazy"
"github.com/tkw1536/goprogram/stream"
)
type Resolver struct {
component.Base
Instances *instances.Instances
prefixes lazy.Lazy[map[string]string] // cached prefixes (from the server)
RefreshInterval time.Duration
handler lazy.Lazy[wdresolve.ResolveHandler] // handler
}
func (resolver *Resolver) Routes() []string { return []string{"/go/", "/wisski/get/"} }
func (resolver *Resolver) Handler(route string, context context.Context, io stream.IOStream) (http.Handler, error) {
var err error
return resolver.handler.Get(func() (p wdresolve.ResolveHandler) {
p.TrustXForwardedProto = true
fallback := &resolvers.Regexp{
Data: map[string]string{},
}
// handle the default domain name!
domainName := resolver.Config.DefaultDomain
if domainName != "" {
fallback.Data[fmt.Sprintf("^https?://(.*)\\.%s", regexp.QuoteMeta(domainName))] = fmt.Sprintf("https://$1.%s", domainName)
io.Printf("registering default domain %s\n", domainName)
}
// handle the extra domains!
for _, domain := range resolver.Config.SelfExtraDomains {
fallback.Data[fmt.Sprintf("^https?://(.*)\\.%s", regexp.QuoteMeta(domain))] = fmt.Sprintf("https://$1.%s", domainName)
io.Printf("registering legacy domain %s\n", domain)
}
// start updating prefixes
resolver.updatePrefixes(io, context)
// resolve the prefixes
p.Resolver = resolvers.InOrder{
resolver,
fallback,
}
return p
}), err
}
func (resolver *Resolver) Target(uri string) string {
return wdresolve.PrefixTarget(resolver, uri)
}
// Prefixes returns a cached list of prefixes
func (resolver *Resolver) Prefixes() (prefixes map[string]string) {
return resolver.prefixes.Get(nil) // by precondition there always is a cached value
}

View file

@ -0,0 +1,19 @@
package component
import (
"context"
"net/http"
"github.com/tkw1536/goprogram/stream"
)
// Servable is a component that is servable
type Servable interface {
Component
// Routes returns the routes served by this servable
Routes() []string
// Handler returns the handler for the requested route
Handler(route string, context context.Context, io stream.IOStream) (http.Handler, error)
}

View file

@ -0,0 +1,30 @@
package sql
import (
"errors"
"io"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
)
var errSQLBackup = errors.New("SQLBackup: Mysqldump returned non-zero exit code")
func (*SQL) BackupName() string {
return "sql.sql"
}
// Backup makes a backup of all SQL databases into the path dest.
func (sql *SQL) Backup(context component.StagingContext) error {
return context.AddFile("", func(file io.Writer) error {
io := context.IO().Streams(file, nil, nil, 0).NonInteractive()
code, err := sql.Stack(sql.Environment).Exec(io, "sql", "mysqldump", "--all-databases")
if err != nil {
return err
}
if code != 0 {
return errSQLBackup
}
return nil
})
}

View file

@ -0,0 +1,140 @@
package sql
import (
"context"
"database/sql"
"fmt"
"net"
"sync/atomic"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/pkg/timex"
)
//
// ========== low-level connection ==========
//
// Exec executes a database-independent database query.
func (sql *SQL) Exec(query string, args ...interface{}) error {
// connect to the server
conn, err := sql.connect("")
if err != nil {
return err
}
// do the query!
{
_, err := conn.Exec(query, args...)
if err != nil {
return err
}
return nil
}
}
// WaitExec waits for the query interface to be able to connect to the database
func (sql *SQL) WaitExec() error {
return timex.TickUntilFunc(func(time.Time) bool {
err := sql.Exec("select 1;")
return err == nil
}, sql.PollContext, sql.PollInterval)
}
//
// ========== connection via gorm ==========
//
// QueryTable returns a gorm.DB to connect to the provided distillery database table
func (sql *SQL) QueryTable(silent bool, table string) (*gorm.DB, error) {
conn, err := sql.connect(sql.Config.DistilleryDatabase)
if err != nil {
return nil, err
}
// gorm configuration
config := &gorm.Config{}
if silent {
config.Logger = logger.Default.LogMode(logger.Silent)
}
// mysql connection
cfg := mysql.Config{
Conn: conn,
DefaultStringSize: 256,
}
// open the gorm connection!
db, err := gorm.Open(mysql.New(cfg), config)
if err != nil {
return nil, err
}
// set the table
db = db.Table(table)
// check that nothing went wrong
if db.Error != nil {
return nil, db.Error
}
return db, nil
}
// WaitQueryTable waits for a connection to succeed via QueryTable
func (sql *SQL) WaitQueryTable() error {
// TODO: Establish a convention on when to wait for this!
return timex.TickUntilFunc(func(time.Time) bool {
_, err := sql.QueryTable(true, models.InstanceTable)
return err == nil
}, sql.PollContext, sql.PollInterval)
}
//
// ========== low-level database connection ==========
//
func (ssql *SQL) connect(database string) (*sql.DB, error) {
conn, err := sql.Open("mysql", ssql.dsn(database))
if err != nil {
return nil, err
}
conn.SetMaxIdleConns(0)
return conn, nil
}
// dsn returns a dsn fof connecting to the database
func (sql *SQL) dsn(database string) string {
user := sql.Config.MysqlAdminUser
pass := sql.Config.MysqlAdminPassword
network := sql.network()
server := sql.ServerURL
return fmt.Sprintf("%s:%s@%s(%s)/%s?charset=utf8&parseTime=True&loc=Local", user, pass, network, server, database)
}
var proxyNameCounter uint64
// network returns the network to use to connect to the database
func (sql *SQL) network() string {
return sql.lazyNetwork.Get(func() (name string) {
network := "tcp"
// register a new DialContext function to use the environment.
// this seems like a bit of a hack, but it works for now.
name = fmt.Sprintf("sql-network-%d", atomic.AddUint64(&proxyNameCounter, 1))
mysqldriver.RegisterDialContext(name, func(ctx context.Context, addr string) (net.Conn, error) {
return sql.Still.Environment.DialContext(ctx, network, addr)
})
return
})
}

View file

@ -0,0 +1,120 @@
package sql
import (
"errors"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/pkg/errorx"
"github.com/FAU-CDI/wisski-distillery/pkg/sqle"
)
var errProvisionInvalidDatabaseParams = errors.New("Provision: Invalid parameters")
var errProvisionInvalidGrant = errors.New("Provision: Grant failed")
// Provision provisions sql-specific resource for the given instance
func (sql *SQL) Provision(instance models.Instance, domain string) error {
return sql.CreateDatabase(instance.SqlDatabase, instance.SqlUsername, instance.SqlPassword)
}
// Purge purges sql-specific resources for the given instance
func (sql *SQL) Purge(instance models.Instance, domain string) error {
return errorx.First(
sql.PurgeDatabase(instance.SqlDatabase),
sql.PurgeUser(instance.SqlUsername),
)
}
// CreateDatabase creates a new database with the given name.
// It then generates a new user, with the name 'user' and the password 'password', that is then granted access to this database.
//
// Provision internally waits for the database to become available.
func (sql *SQL) CreateDatabase(name, user, password string) error {
// NOTE(twiesing): We shouldn't use string concat to build sql queries.
// But the driver doesn't support using query params for this particular query.
// Apparently it's a "feature", see https://github.com/go-sql-driver/mysql/issues/398#issuecomment-169951763.
// quick and dirty check to make sure that all the names won't sql inject.
if !sqle.IsSafeDatabaseLiteral(name) || !sqle.IsSafeDatabaseSingleQuote(user) || !sqle.IsSafeDatabaseSingleQuote(password) {
return errProvisionInvalidDatabaseParams
}
// We use the sql shell here, because not only can we not use query params, but the driver outright rejects queries.
// Queries of the form "CREATE USER 'test'@'%' IDENTIFIED BY 'test'; FLUSH PRIVILEGES;" return error 1064 when using driver, but are fine with the shell.
// This should be fixed eventually, but I have no idea how.
if err := sql.unsafeWaitShell(); err != nil {
return err
}
query := "CREATE DATABASE `" + name + "`;" +
"CREATE USER '" + user + "'@'%' IDENTIFIED BY '" + password + "';" +
"GRANT ALL PRIVILEGES ON `" + name + "`.* TO `" + user + "`@`%`; FLUSH PRIVILEGES;"
if !sql.unsafeQueryShell(query) {
return errProvisionInvalidGrant
}
return nil
}
var errCreateSuperuserGrant = errors.New("CreateSuperUser: Grant failed")
// CreateSuperuser createsa new user, with the name 'user' and the password 'password'.
// It then grants this user superuser status in the database.
//
// CreateSuperuser internally waits for the database to become available.
func (sql *SQL) CreateSuperuser(user, password string, allowExisting bool) error {
// NOTE(twiesing): This function unsafely uses the shell directly to create a superuser.
// This is for two reasons:
// (1) this is used during bootstraping
// (2) The underlying driver doesn't support "GRANT ALL PRIVILEGES"
// See also [sql.Provision].
if !sqle.IsSafeDatabaseSingleQuote(user) || !sqle.IsSafeDatabaseSingleQuote(password) {
return errProvisionInvalidDatabaseParams
}
if err := sql.unsafeWaitShell(); err != nil {
return err
}
var IfNotExists string
if allowExisting {
IfNotExists = "IF NOT EXISTS"
}
query := "CREATE USER " + IfNotExists + " '" + user + "'@'%' IDENTIFIED BY '" + password + "';" +
"GRANT ALL PRIVILEGES ON *.* TO '" + user + "'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;"
if !sql.unsafeQueryShell(query) {
return errCreateSuperuserGrant
}
return nil
}
var errPurgeUser = errors.New("PurgeUser: Failed to drop user")
// SQLPurgeUser deletes the specified user from the database
func (sql *SQL) PurgeUser(user string) error {
if !sqle.IsSafeDatabaseSingleQuote(user) {
return errPurgeUser
}
query := "DROP USER IF EXISTS '" + user + "'@'%';" +
"FLUSH PRIVILEGES;"
if !sql.unsafeQueryShell(query) {
return errPurgeUser
}
return nil
}
var errSQLPurgeDB = errors.New("unable to drop database: unsafe database name")
// SQLPurgeDatabase deletes the specified db from the database
func (sql *SQL) PurgeDatabase(db string) error {
if !sqle.IsSafeDatabaseLiteral(db) {
return errSQLPurgeDB
}
return sql.Exec("DROP DATABASE IF EXISTS `" + db + "`")
}

View file

@ -0,0 +1,35 @@
package sql
import (
"io"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/tkw1536/goprogram/stream"
)
func (*SQL) SnapshotNeedsRunning() bool { return false }
func (*SQL) SnapshotName() string { return "sql" }
func (sql *SQL) Snapshot(wisski models.Instance, context component.StagingContext) error {
return context.AddDirectory(".", func() error {
return context.AddFile(wisski.SqlDatabase+".sql", func(file io.Writer) error {
return sql.SnapshotDB(context.IO(), file, wisski.SqlDatabase)
})
})
}
// SnapshotDB makes a backup of the sql database into dest.
func (sql *SQL) SnapshotDB(io stream.IOStream, dest io.Writer, database string) error {
io = io.Streams(dest, nil, nil, 0).NonInteractive()
code, err := sql.Stack(sql.Environment).Exec(io, "sql", "mysqldump", "--databases", database)
if err != nil {
return err
}
if code != 0 {
return errSQLBackup
}
return nil
}

View file

@ -0,0 +1,2 @@
DOCKER_NETWORK_NAME=${DOCKER_NETWORK_NAME}
HTTPS_ENABLED=${HTTPS_ENABLED}

View file

@ -0,0 +1,53 @@
package sql
import (
"context"
"embed"
"path/filepath"
"time"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
"github.com/FAU-CDI/wisski-distillery/pkg/lazy"
)
type SQL struct {
component.Base
ServerURL string // upstream server url
PollContext context.Context // context to abort polling with
PollInterval time.Duration // duration to wait for during wait
lazyNetwork lazy.Lazy[string]
}
func (sql *SQL) Path() string {
return filepath.Join(sql.Still.Config.DeployRoot, "core", "sql")
}
func (*SQL) Context(parent component.InstallationContext) component.InstallationContext {
return parent
}
//go:embed all:sql
//go:embed sql.env
var resources embed.FS
func (sql *SQL) Stack(env environment.Environment) component.StackWithResources {
return component.MakeStack(sql, env, component.StackWithResources{
Resources: resources,
ContextPath: "sql",
EnvPath: "sql.env",
EnvContext: map[string]string{
"DOCKER_NETWORK_NAME": sql.Config.DockerNetworkName,
"HTTPS_ENABLED": sql.Config.HTTPSEnabledEnv(),
},
MakeDirsPerm: environment.DefaultDirPerm,
MakeDirs: []string{
"data",
},
})
}

View file

@ -0,0 +1,39 @@
version: "3.7"
services:
sql:
image: mariadb
volumes:
- "./data/:/var/lib/mysql"
ports:
- 127.0.0.1:3306:3306
labels:
- "eu.wiss-ki.barrel.distillery=${DOCKER_NETWORK_NAME}"
environment:
# This combination of environment variables will configure a passwordless root user
# that can only connect to the container from 'localhost'.
# This means we can only connect using 'docker-compose exec sql mysql -C '...' '.
- "MYSQL_ALLOW_EMPTY_PASSWORD=yes"
- "MYSQL_ROOT_HOST=localhost"
restart: always
phpmyadmin:
image: phpmyadmin/phpmyadmin
environment:
- "PMA_HOST=sql"
- "HIDE_PHP_VERSION=true"
- "UPLOAD_LIMIT=100M"
# phpmyadmin running on localhost:8080 so that we can easily access the system graphically.
# By default no admin account is created, so initial shell access to make one is needed.
ports:
- 127.0.0.1:8080:80
labels:
- "eu.wiss-ki.barrel.distillery=${DOCKER_NETWORK_NAME}"
depends_on:
- sql
restart: always
networks:
default:
name: ${DOCKER_NETWORK_NAME}
external: true

View file

@ -0,0 +1,121 @@
package sql
import (
"errors"
"fmt"
"time"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/pkg/logging"
"github.com/FAU-CDI/wisski-distillery/pkg/sqle"
"github.com/FAU-CDI/wisski-distillery/pkg/timex"
"github.com/tkw1536/goprogram/exit"
"github.com/tkw1536/goprogram/stream"
)
// Shell runs a mysql shell with the provided databases.
//
// NOTE(twiesing): This command should not be used to connect to the database or execute queries except in known situations.
func (sql *SQL) Shell(io stream.IOStream, argv ...string) (int, error) {
return sql.Stack(sql.Environment).Exec(io, "sql", "mysql", argv...)
}
// unsafeWaitShell waits for a connection via the database shell to succeed
func (sql *SQL) unsafeWaitShell() error {
n := stream.FromNil()
return timex.TickUntilFunc(func(time.Time) bool {
code, err := sql.Shell(n, "-e", "select 1;")
return err == nil && code == 0
}, sql.PollContext, sql.PollInterval)
}
// unsafeQuery shell executes a raw database query.
func (sql *SQL) unsafeQueryShell(query string) bool {
code, err := sql.Shell(stream.FromNil(), "-e", query)
return err == nil && code == 0
}
var errSQLUnableToCreateUser = errors.New("unable to create administrative user")
var errSQLUnsafeDatabaseName = errors.New("distillery database has an unsafe name")
var errSQLUnableToMigrate = exit.Error{
Message: "unable to migrate %s table: %s",
ExitCode: exit.ExitGeneric,
}
// Update initializes or updates the SQL database.
func (sql *SQL) Update(io stream.IOStream) error {
// unsafely create the admin user!
{
if err := sql.unsafeWaitShell(); err != nil {
return err
}
logging.LogMessage(io, "Creating administrative user")
{
username := sql.Config.MysqlAdminUser
password := sql.Config.MysqlAdminPassword
if err := sql.CreateSuperuser(username, password, true); err != nil {
return errSQLUnableToCreateUser
}
}
}
// create the admin user
logging.LogMessage(io, "Creating sql database")
{
if !sqle.IsSafeDatabaseLiteral(sql.Config.DistilleryDatabase) {
return errSQLUnsafeDatabaseName
}
createDBSQL := fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`;", sql.Config.DistilleryDatabase)
if err := sql.Exec(createDBSQL); err != nil {
return err
}
}
// wait for the database to come up
logging.LogMessage(io, "Waiting for database update to be complete")
sql.WaitQueryTable()
tables := []struct {
name string
model any
table string
}{
{
"instance",
&models.Instance{},
models.InstanceTable,
},
{
"metadata",
&models.Metadatum{},
models.MetadataTable,
},
{
"snapshot",
&models.Export{},
models.ExportTable,
},
{
"lock",
&models.Lock{},
models.LockTable,
},
}
// migrate all of the tables!
return logging.LogOperation(func() error {
for _, table := range tables {
logging.LogMessage(io, "migrating %q table", table.name)
db, err := sql.QueryTable(false, table.table)
if err != nil {
return errSQLUnableToMigrate.WithMessageF(table.name, "unable to access table")
}
if err := db.AutoMigrate(table.model); err != nil {
return errSQLUnableToMigrate.WithMessageF(table.name, err)
}
}
return nil
}, io, "migrating database tables")
}

View file

@ -0,0 +1 @@
DOCKER_NETWORK_NAME=${DOCKER_NETWORK_NAME}

View file

@ -0,0 +1,37 @@
package ssh
import (
"embed"
"path/filepath"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
)
type SSH struct {
component.Base
}
func (ssh *SSH) Path() string {
return filepath.Join(ssh.Still.Config.DeployRoot, "core", "ssh")
}
func (*SSH) Context(parent component.InstallationContext) component.InstallationContext {
return parent
}
//go:embed all:ssh
//go:embed ssh.env
var resources embed.FS
func (ssh *SSH) Stack(env environment.Environment) component.StackWithResources {
return component.MakeStack(ssh, env, component.StackWithResources{
Resources: resources,
ContextPath: "ssh",
EnvPath: "ssh.env",
EnvContext: map[string]string{
"DOCKER_NETWORK_NAME": ssh.Config.DockerNetworkName,
},
})
}

View file

@ -0,0 +1,20 @@
version: "3.7"
services:
ssh:
image: ghcr.io/tkw1536/dockersshd:latest
command: -hostkey /keys/hostkey -shell /user_shell.sh -keylabel eu.wiss-ki.barrel.authfile -userlabel eu.wiss-ki.barrel.slug -L triplestore:7200 -L phpmyadmin:80 -L sql:3306
ports:
- "2222:2222"
volumes:
- './data/keys:/keys'
- '/var/run/docker.sock:/var/run/docker.sock:ro'
labels:
- "eu.wiss-ki.barrel.distillery=${DOCKER_NETWORK_NAME}"
restart: always
networks:
default:
name: ${DOCKER_NETWORK_NAME}
external: true

View file

@ -0,0 +1,297 @@
// Package stack implements a docker compose stack
package component
import (
"bufio"
"bytes"
"io/fs"
"path/filepath"
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
"github.com/FAU-CDI/wisski-distillery/pkg/fsx"
"github.com/FAU-CDI/wisski-distillery/pkg/unpack"
"github.com/pkg/errors"
"github.com/tkw1536/goprogram/stream"
)
// Stack represents a 'docker compose' stack living in a specific directory
//
// NOTE(twiesing): In the current implementation this requires a 'docker' executable on the system.
// This executable must be capable of the 'docker compose' command.
// In the future the idea is to replace this with a native docker compose client.
type Stack struct {
Dir string // Directory this Stack is located in
Env environment.Environment
DockerExecutable string // Path to the native docker executable to use
}
var errStackUpdatePull = errors.New("Stack.Update: Pull returned non-zero exit code")
var errStackUpdateBuild = errors.New("Stack.Update: Build returned non-zero exit code")
// Update pulls, builds, and then optionally starts this stack.
// This does not have a direct 'docker compose' shell equivalent.
//
// See also Up.
func (ds Stack) Update(io stream.IOStream, start bool) error {
{
code, err := ds.compose(io, "pull")
if err != nil {
return err
}
if code != 0 {
return errStackUpdatePull
}
}
{
code, err := ds.compose(io, "build", "--pull")
if err != nil {
return err
}
if code != 0 {
return errStackUpdateBuild
}
}
if start {
return ds.Up(io)
}
return nil
}
var errStackUp = errors.New("Stack.Up: Up returned non-zero exit code")
// Up creates and starts the containers in this Stack.
// It is equivalent to 'docker compose up --remove-orphans --detach' on the shell.
func (ds Stack) Up(io stream.IOStream) error {
code, err := ds.compose(io, "up", "--remove-orphans", "--detach")
if err != nil {
return err
}
if code != 0 {
return errStackUp
}
return nil
}
// Exec executes an executable in the provided running service.
// It is equivalent to 'docker compose exec $service $executable $args...'.
//
// It returns the exit code of the process.
func (ds Stack) Exec(io stream.IOStream, service, executable string, args ...string) (int, error) {
compose := []string{"exec"}
if io.StdinIsATerminal() {
compose = append(compose, "-ti")
}
compose = append(compose, service)
compose = append(compose, executable)
compose = append(compose, args...)
return ds.compose(io, compose...)
}
// Run runs a command in a running container with the given executable.
// It is equivalent to 'docker compose run [--rm] $service $executable $args...'.
//
// It returns the exit code of the process.
func (ds Stack) Run(io stream.IOStream, autoRemove bool, service, command string, args ...string) (int, error) {
compose := []string{"run"}
if autoRemove {
compose = append(compose, "--rm")
}
if !io.StdinIsATerminal() {
compose = append(compose, "-T")
}
compose = append(compose, service, command)
compose = append(compose, args...)
code, err := ds.compose(io, compose...)
if err != nil {
return environment.ExecCommandError, nil
}
return code, nil
}
var errStackRestart = errors.New("Stack.Restart: Restart returned non-zero exit code")
// Restart restarts all containers in this Stack.
// It is equivalent to 'docker compose restart' on the shell.
func (ds Stack) Restart(io stream.IOStream) error {
code, err := ds.compose(io, "restart")
if err != nil {
return err
}
if code != 0 {
return errStackRestart
}
return nil
}
var errStackPs = errors.New("Stack.Ps: Down returned non-zero exit code")
// Ps returns the ids of the containers currently running
func (ds Stack) Ps(io stream.IOStream) ([]string, error) {
// create a buffer
var buffer bytes.Buffer
// read the ids from the command!
code, err := ds.compose(io.Streams(&buffer, nil, nil, 0), "ps", "-q")
if err != nil {
return nil, err
}
if code != 0 {
return nil, errStackPs
}
// scan each of the lines
var results []string
scanner := bufio.NewScanner(&buffer)
for scanner.Scan() {
if text := scanner.Text(); text != "" {
results = append(results, text)
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
// return them!
return results, nil
}
var errStackDown = errors.New("Stack.Down: Down returned non-zero exit code")
// Down stops and removes all containers in this Stack.
// It is equivalent to 'docker compose down -v' on the shell.
func (ds Stack) Down(io stream.IOStream) error {
code, err := ds.compose(io, "down", "-v")
if err != nil {
return err
}
if code != 0 {
return errStackDown
}
return nil
}
// compose executes a 'docker compose' command on this stack.
//
// NOTE(twiesing): Check if this can be replaced by an internal call to libcompose.
// But probably not.
func (ds Stack) compose(io stream.IOStream, args ...string) (int, error) {
if ds.DockerExecutable == "" {
var err error
ds.DockerExecutable, err = ds.Env.LookPathAbs("docker")
if err != nil {
return environment.ExecCommandError, err
}
}
return ds.Env.Exec(io, ds.Dir, ds.DockerExecutable, append([]string{"compose"}, args...)...), nil
}
// StackWithResources represents a Stack that can be automatically installed from a set of resources.
// See the [Install] method.
type StackWithResources struct {
Stack
// Installable enabled installing several resources from a (potentially embedded) filesystem.
//
// The Resources holds these, with appropriate resources specified below.
// These all refer to paths within the Resource filesystem.
Resources fs.FS
ContextPath string // the 'docker compose' stack context, containing e.g. 'docker-compose.yml'.
EnvPath string // the '.env' template, will be installed using [unpack.InstallTemplate].
EnvContext map[string]string // context when instantiating the '.env' template
CopyContextFiles []string // Files to copy from the installation context
MakeDirsPerm fs.FileMode // permission for dirctories, defaults to [environment.DefaultDirCreate]
MakeDirs []string // directories to ensure that exist
TouchFilesPerm fs.FileMode // permission for new files to touch, defaults to [environment.DefaultFileCreate]
TouchFiles []string // Files to 'touch', i.e. ensure that exist; guaranteed to be run after MakeDirs
}
// InstallationContext is a context to install data in
type InstallationContext map[string]string
// Install installs or updates this stack into the directory specified by stack.Stack().
//
// Installation is non-interactive, but will provide debugging output onto io.
// InstallationContext
func (is StackWithResources) Install(io stream.IOStream, context InstallationContext) error {
env := is.Stack.Env
if is.ContextPath != "" {
// setup the base files
if err := unpack.InstallDir(
env,
is.Dir,
is.ContextPath,
is.Resources,
func(dst, src string) {
io.Printf("[install] %s\n", dst)
},
); err != nil {
return err
}
}
// configure .env
envDest := filepath.Join(is.Dir, ".env")
if is.EnvPath != "" && is.EnvContext != nil {
io.Printf("[config] %s\n", envDest)
if err := unpack.InstallTemplate(
env,
envDest,
is.EnvContext,
is.EnvPath,
is.Resources,
); err != nil {
return err
}
}
// make sure that certain dirs exist
for _, name := range is.MakeDirs {
// find the destination!
dst := filepath.Join(is.Dir, name)
io.Printf("[make] %s\n", dst)
if is.MakeDirsPerm == fs.FileMode(0) {
is.MakeDirsPerm = environment.DefaultDirPerm
}
if err := env.MkdirAll(dst, is.MakeDirsPerm); err != nil {
return err
}
}
// copy files from the context!
for _, name := range is.CopyContextFiles {
// find the source!
src, ok := context[name]
if !ok {
return errors.Errorf("Missing file from context: %q", src)
}
// find the destination!
dst := filepath.Join(is.Dir, name)
// copy over file from context
io.Printf("[copy] %s (from %s)\n", dst, src)
if err := fsx.CopyFile(env, dst, src); err != nil {
return errors.Wrapf(err, "Unable to copy file %s", src)
}
}
// make sure that certain files exist
for _, name := range is.TouchFiles {
// find the destination!
dst := filepath.Join(is.Dir, name)
io.Printf("[touch] %s\n", dst)
if err := fsx.Touch(env, dst, is.TouchFilesPerm); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,47 @@
package static
import (
"encoding/json"
"html/template"
)
// Assets represents a group of assets to be included inside a template.
//
// Assets are generated using the 'build.mjs' script.
// The script is called using 'go:generate', which stores variables in the form of 'Assets{{Name}}' inside this package.
//
// The build script roughly works as follows:
// - Delete any previously generated distribution directory.
// - Bundle the entrypoint sources under 'src/entry/{{Name}}/index.{ts,css}' together with the base './src/base/index.{ts,css}'
// - Store the output inside the 'dist' directory
// - Generate new constants of the form {{Name}}
//
// Each asset group should be registered as a parameter to the 'go:generate' line.
type Assets struct {
Scripts string // <script> tags inserted by the asset
Styles string // <link> tags inserted by the asset
}
//go:generate node build.mjs HomeHome ControlIndex ControlInstance
// MustParse parses a new template from the given source
// and registers the Asset functions to it.
// See [Assets.RegisterFuncs].
func (assets *Assets) MustParse(value string) *template.Template {
return template.Must(assets.RegisterFuncs(template.New("")).Parse(value))
}
// RegisterFuncs registers three new template functions called "JS", "CSS" and "json".
//
// "JS" and "CSS" take no arguments, and return appropriate tags to be inserted into html.
// json takes a single argument of any type, and returns it's encoding as a string to be inserted into the page.
func (assets *Assets) RegisterFuncs(t *template.Template) *template.Template {
return t.Funcs(template.FuncMap{
"JS": func() template.HTML { return template.HTML(assets.Scripts) },
"CSS": func() template.HTML { return template.HTML(assets.Styles) },
"json": func(data any) (string, error) {
bytes, err := json.Marshal(data)
return string(bytes), err
},
})
}

View file

@ -0,0 +1,21 @@
package static
// This file was automatically generated. Do not edit.
// AssetsHomeHome contains assets for the 'HomeHome' entrypoint.
var AssetsHomeHome = Assets{
Scripts: `<script type="module" src="/static/HomeHome.38d394c2.js"></script><script src="/static/HomeHome.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/HomeHome.38d394c2.js"></script><script src="/static/HomeHome.38d394c2.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/HomeHome.a75f04fa.css"><link rel="stylesheet" href="/static/HomeHome.38d394c2.css">`,
}
// AssetsControlIndex contains assets for the 'ControlIndex' entrypoint.
var AssetsControlIndex = Assets{
Scripts: `<script type="module" src="/static/HomeHome.38d394c2.js"></script><script src="/static/HomeHome.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/ControlIndex.cfbf936d.js"></script><script src="/static/ControlIndex.613b02c2.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/HomeHome.a75f04fa.css"><link rel="stylesheet" href="/static/ControlIndex.6d59e220.css"><link rel="stylesheet" href="/static/ControlIndex.6d2ae968.css">`,
}
// AssetsControlInstance contains assets for the 'ControlInstance' entrypoint.
var AssetsControlInstance = Assets{
Scripts: `<script nomodule="" defer src="/static/ControlIndex.613b02c2.js"></script><script type="module" src="/static/ControlIndex.cfbf936d.js"></script><script type="module" src="/static/HomeHome.38d394c2.js"></script><script src="/static/HomeHome.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/ControlInstance.66b95713.js"></script><script src="/static/ControlInstance.9cc7166d.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/HomeHome.a75f04fa.css"><link rel="stylesheet" href="/static/ControlIndex.6d59e220.css"><link rel="stylesheet" href="/static/ControlIndex.6d2ae968.css"><link rel="stylesheet" href="/static/ControlInstance.38d394c2.css">`,
}

View file

@ -0,0 +1,127 @@
import { Parcel } from "@parcel/core"
import { mkdir, rm, writeFile, readFile, unlink, rmdir, } from "fs/promises"
import { join } from "path"
import { parse as parseHTML } from 'node-html-parser';
//
// PARAMETERS
//
const ENTRYPOINTS = process.argv.slice(2)
const ENTRY_DIR = join('.', '.entry-cache') // directory to place entries into
const DIST_DIR = join('.', 'dist')
const PUBLIC_DIR = '/static/'
const DEST_PACKAGE = process.env.GOPACKAGE ?? 'static'
const DEST_FILE = (() => {
const source = (process.env.GOFILE ?? 'assets.go')
const base = source.substring(0, source.length - '.go'.length)
return base + '_dist.go'
})()
//
// PREPARE DIRECTORIES
//
process.stdout.write('Preparing directories ...')
await Promise.all([
mkdir(ENTRY_DIR, { recursive: true }),
rm(DIST_DIR, { recursive: true, force: true })
])
console.log(' Done.')
//
// WRITE ENTRY POINTS
//
process.stdout.write('Collecting entry points ')
const entries = await Promise.all(ENTRYPOINTS.map(async (name) => {
const entry = {
'name': name,
'bundleName': name + '.html',
'src': join(ENTRY_DIR, name + '.html'),
}
const content = `
<script type='module' src='../src/base/index.ts'></script>
<script type='module' src='../src/entry/${name}/index.ts'></script>
<link rel='stylesheet' href='../src/entry/${name}/index.css'>
`;
await writeFile(entry.src, content)
process.stdout.write('.')
return entry;
}))
console.log(' Done.')
//
// BUNDLEING
//
process.stdout.write('Bundleing assets ...')
const bundler = new Parcel({
entries: entries.map(e => e.src),
defaultConfig: '@parcel/config-default',
shouldDisableCache: true,
shouldContentHash: true,
defaultTargetOptions: {
shouldOptimize: true,
shouldScopeHoist: true,
sourceMaps: false,
distDir: DIST_DIR,
publicUrl: PUBLIC_DIR,
engines: {
browsers: "defaults",
}
}
});
const { bundleGraph } = await bundler.run()
console.log(' Done.')
//
// FIND ASSETS IN OUTPUT
//
process.stdout.write('Find Assets in Output ')
const bundles = bundleGraph.getBundles()
const assets = await Promise.all(entries.map(async (entry) => {
const mainBundle = bundles.find(b => b.name === entry.bundleName)
if (mainBundle === undefined) throw new Error('Unable to find bundle for ' + entry.name)
// read, then delete the generated output file
const { filePath } = mainBundle
const html = parseHTML(await readFile(filePath))
await unlink(filePath)
const scripts = html.querySelectorAll('script').map(script => script.outerHTML).join('')
const links = html.querySelectorAll('link').map(link => link.outerHTML).join('')
process.stdout.write('.')
return { ...entry, scripts, links }
}))
console.log(' Done.')
//
// GENERATE GO
//
process.stdout.write(`Writing ${DEST_FILE} ...`)
const goAssets = assets.map(({ name, scripts, links }) => {
return `
// Assets${name} contains assets for the '${name}' entrypoint.
var Assets${name} = Assets{
\tScripts: \`${scripts}\`,
\tStyles: \`${links}\`,\t
}`.trim()
}).join('\n\n')
const goSource = `package ${DEST_PACKAGE}
// This file was automatically generated. Do not edit.
${goAssets}
`;
await writeFile(DEST_FILE, goSource)
console.log(' Done.')

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
.wisski{padding:1em}.wisski h3{padding:0}.wisski a.pure-button{float:right;position:relative;bottom:1em}.wisski.running{background-color:#9ada07}.wisski.stopped{background-color:#ff7a7a}

View file

@ -0,0 +1 @@
.modal-terminal{width:66vw;height:66vh;background-color:#fff;background-clip:padding-box;-webkit-background-clip:padding-box;z-index:1000;border:17vh solid #000c;border-width:17vh 17vw;margin:-17vh -17vw;position:fixed;top:17vh;left:17vw;overflow:auto}.modal-terminal button{z-index:1001;position:fixed;top:17vh;right:17vw}.modal-terminal pre,.modal-terminal button{margin:5px}

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={},r={},o=e.parcelRequireafa4;null==o&&((o=function(e){if(e in n)return n[e].exports;if(e in r){var o=r[e];delete r[e];var i={id:e,exports:{}};return n[e]=i,o.call(i.exports,i,i.exports),i.exports}var l=new Error("Cannot find module '"+e+"'");throw l.code="MODULE_NOT_FOUND",l}).register=function(e,n){r[e]=n},e.parcelRequireafa4=o),o("gJkWt");

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={},r=e.parcelRequireafa4;null==r&&((r=function(e){if(e in n)return n[e].exports;if(e in o){var r=o[e];delete o[e];var i={id:e,exports:{}};return n[e]=i,r.call(i.exports,i,i.exports),i.exports}var f=new Error("Cannot find module '"+e+"'");throw f.code="MODULE_NOT_FOUND",f}).register=function(e,n){o[e]=n},e.parcelRequireafa4=r),r("8s4Fe")}();

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,11 @@
{
"name": "wisski-distillery-frontend",
"version": "1.0.0",
"license": "AGPL-3.0-only",
"private": true,
"dependencies": {
"dayjs": "^1.11.5",
"node-html-parser": "^6.1.1",
"parcel": "^2.7.0"
}
}

View file

@ -0,0 +1,50 @@
body {
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
header,
main,
footer {
margin: 2em;
}
.padding {
padding: 1em;
}
.overflow {
overflow-x: auto;
}
.overflow table {
width: 100%;
}
.overflow table td,
.overflow table th{
padding: .5em .5em;
}
.overflow table td:not(:last-child),
.overflow table th:not(:last-child) {
width: 1px;
text-align: left;
white-space: nowrap;
}
.overflow table td:last-child,
.overflow table th:last-child {
white-space: nowrap;
}
.hspace {
display: block;
height: 1em;
}
.pure-button-action {
background-color: rgb(66, 184, 221) !important;
}
.pure-button-success {
background-color: rgb(28, 184, 65) !important;
}

View file

@ -0,0 +1,4 @@
import "purecss/build/pure.css"
import "purecss/build/grids-responsive.css"
import "./index.css"

View file

@ -0,0 +1,21 @@
.wisski {
padding: 1em;
}
.wisski h3 {
padding: 0;
}
.wisski a.pure-button {
float: right;
position: relative;
bottom: 1em;
}
.wisski.running {
background-color: #9ADA07;
}
.wisski.stopped {
background-color: #ff7a7a;
}

View file

@ -0,0 +1,2 @@
import "~/src/lib/remote"
import "~/src/lib/highlight"

View file

@ -0,0 +1 @@
@import url("../ControlIndex/index.css")

View file

@ -0,0 +1 @@
import "../ControlIndex/index"

View file

@ -0,0 +1,22 @@
.header-link {
position: relative;
left: 0.5em;
opacity: 0;
font-size: 0.8em;
transition: opacity 0.2s ease-in-out 0.1s;
-webkit-transition: opacity 0.2s ease-in-out 0.1s;
-moz-transition: opacity 0.2s ease-in-out 0.1s;
-ms-transition: opacity 0.2s ease-in-out 0.1s;
text-decoration: none;
}
h2:hover .header-link,
h3:hover .header-link,
h4:hover .header-link,
h5:hover .header-link,
h6:hover .header-link {
color: black !important;
opacity: 1;
}

View file

@ -0,0 +1,21 @@
import "./index.css"
/** Adapted from http://blog.parkermoore.de/2014/08/01/header-anchor-links-in-vanilla-javascript-for-github-pages-and-jekyll/ */
const anchorForId = (id: string) => {
const anchor = document.createElement("a")
anchor.className = "header-link"
anchor.href = "#" + id
anchor.innerHTML = "#"
return anchor
}
const linkifyAnchors = (level: number) => {
const headers = document.getElementsByTagName("h" + level);
Array.from(headers).forEach((header) => {
if (typeof header.id === "undefined" || header.id === "") return
header.appendChild(anchorForId(header.id))
})
}
// linkify all the anchors from 1 ... 6
(new Array(6)).fill(0).forEach((_, i) => linkifyAnchors(i + 1))

View file

@ -0,0 +1,51 @@
import dayjs from "dayjs"
const types: Record<string, (element: HTMLElement) => HTMLElement | string> = {
"date": (element) => {
return dayjs(element.innerText).format('YYYY-MM-DD HH:mm:ss ([UTC]Z)')
},
"path": (element) => {
const text = element.innerText.split("/");
return text[text.length - 1];
},
"pathbuilder": (element) => {
// create a link and get the blob
const filename = (element.getAttribute('data-name') ?? 'pathbuilder') + ".xml"
const [link, blob] = make_download_link(filename, element.innerText, "application/xml")
link.className = "pure-button"
const title = filename + ' (' + blob.size + ' Bytes)';
link.append(title)
return link
}
}
const make_download_link = (filename: string, content: string, type: string): [HTMLAnchorElement, Blob] => {
const blob = new Blob(
[content],
{
type: type ?? "text/plain"
}
);
const link = document.createElement("a")
link.target = "_blank"
link.download = filename
link.href = URL.createObjectURL(blob)
return [link, blob]
}
Object.keys(types).forEach(key => {
const f = types[key];
const elements = document.querySelectorAll("code." + key) as NodeListOf<HTMLElement>
elements.forEach(element => {
const newElement = f(element)
if (typeof newElement === 'string') {
element.innerHTML = ""
element.appendChild(document.createTextNode(newElement))
return
}
element.parentNode!.replaceChild(newElement, element)
})
})

View file

@ -0,0 +1,42 @@
.modal-terminal {
width: 66vw;
height: 66vh;
position: fixed;
left: 17vw;
top: 17vh;
background-color: white;
background-clip: padding-box;
-webkit-background-clip: padding-box;
border-left: 17vw solid rgba(0, 0, 0, 0.8);
border-right: 17vw solid rgba(0, 0, 0, 0.8);
margin-left: -17vw;
margin-right: -17vw;
border-top: 17vh solid rgba(0, 0, 0, 0.8);
border-bottom: 17vh solid rgba(0, 0, 0, 0.8);
margin-top: -17vh;
margin-bottom: -17vh;
overflow: auto;
z-index: 1000;
}
.modal-terminal button {
position: fixed;
top: 17vh;
right: 17vw;
z-index: 1001;
}
.modal-terminal pre,
.modal-terminal button
{
margin: 5px;
}

View file

@ -0,0 +1,125 @@
import "./index.css"
import connectSocket from './socket';
type Println = ((line: string, flush?: boolean) => void) & {
paintedFrames: number;
missedFrames: number;
}
/**
* makeTextBuffer returns a println() function that efficiently writes text into target, and keeps at most size elements in the traceback.
* scrollContainer is used to scroll on every painted update.
*/
function makeTextBuffer(target: HTMLElement, scrollContainer: HTMLElement, size: number): Println {
let lastAnimationFrame: number | null = null; // last scheduled animation frame
const buffer: Array<string> = []; // the internal buffer of lines
const paint = () => {
println.paintedFrames++
target.innerText = buffer.join("\n")
scrollContainer.scrollTop = scrollContainer.scrollHeight
lastAnimationFrame = null
}
const println = (line: string, flush?: boolean) => {
// add the line
buffer.push(line)
if (size !== 0 && buffer.length > size) {
buffer.splice(0, buffer.length - size)
}
// and update the browser in the next animation frame
if (lastAnimationFrame !== null) {
println.missedFrames++
window.cancelAnimationFrame(lastAnimationFrame)
}
// force a repaint!
if(flush) return paint();
// schedule an animation frame
lastAnimationFrame = window.requestAnimationFrame(paint);
}
println.paintedFrames = 0;
println.missedFrames = 0;
return println;
}
const elements = document.getElementsByClassName('remote-action')
Array.from(elements).forEach((element) => {
const action = element.getAttribute('data-action') as string;
const reload = element.hasAttribute('data-force-reload');
const param = element.getAttribute('data-param') as string | undefined;
const bufferSize = (function () {
const number = parseInt(element.getAttribute('data-buffer') ?? "", 10) ?? 0;
return (isFinite(number) && number > 0) ? number : 0;
})()
element.addEventListener('click', function (ev) {
ev.preventDefault();
// 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(reload ? "Close & Reload" : "Close")
button.addEventListener('click', function (event) {
event.preventDefault();
if (reload) {
button.setAttribute('disabled', 'disabled');
target.innerHTML = 'Reloading page ...'
location.reload()
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);
if (typeof param === 'string') {
socket.send(param);
}
}, (data) => {
println(data);
}).then(() => {
println("Connection closed.", true)
close();
}).catch(() => {
println("Connection errored.", true)
close();
});
});
})

View file

@ -0,0 +1,11 @@
export default function connectSocket(onOpen: (socket: WebSocket) => void, onData: (data: any) => void): Promise<CloseEvent> {
return new Promise((rs, rj) => {
const socket = new WebSocket(location.href.replace('http', 'ws'));
socket.onclose = rs;
socket.onerror = rj;
socket.onmessage = (ev) => onData(ev.data)
socket.onopen = () => onOpen(socket);
});
}

View file

@ -0,0 +1,32 @@
// Package static implements serving of fully static resources
package static
import (
"context"
"embed"
"io/fs"
"net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/tkw1536/goprogram/stream"
)
type Static struct {
component.Base
}
func (*Static) Routes() []string { return []string{"/static/"} }
//go:embed dist
var staticFS embed.FS
func (static *Static) Handler(route string, context context.Context, io stream.IOStream) (http.Handler, error) {
// take the filesystem
fs, err := fs.Sub(staticFS, "dist")
if err != nil {
return nil, err
}
// and serve it
return http.StripPrefix(route, http.FileServer(http.FS(fs))), nil
}

View file

@ -0,0 +1,105 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
"paths": {
"~/*": ["./*"],
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,44 @@
package triplestore
import (
"encoding/json"
"io"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
)
func (ts *Triplestore) BackupName() string { return "triplestore" }
// Backup makes a backup of all Triplestore repositories databases into the path dest.
func (ts *Triplestore) Backup(context component.StagingContext) error {
// list all the directories
repos, err := ts.listRepositories()
if err != nil {
return err
}
// then backup each file separatly
return context.AddDirectory("", func() error {
for _, repo := range repos {
if err := context.AddFile(repo.ID+".nq", func(file io.Writer) error {
_, err := ts.SnapshotDB(file, repo.ID)
return err
}); err != nil {
return err
}
}
return nil
})
}
func (ts Triplestore) listRepositories() (repos []Repository, err error) {
res, err := ts.OpenRaw("GET", "/rest/repositories", nil, "", "application/json")
if err != nil {
return nil, err
}
defer res.Body.Close()
err = json.NewDecoder(res.Body).Decode(&repos)
return
}

View file

@ -0,0 +1,57 @@
# This file is used to initialize a new GraphDB repository.
# In this file the variables ${GRAPHDB_REPO} and ${INSTANCE_DOMAIN} will be replaced.
# All other variables will be left untouched.
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.
@prefix rep: <http://www.openrdf.org/config/repository#>.
@prefix sr: <http://www.openrdf.org/config/repository/sail#>.
@prefix sail: <http://www.openrdf.org/config/sail#>.
@prefix owlim: <http://www.ontotext.com/trree/owlim#>.
[] a rep:Repository ;
rep:repositoryID "${GRAPHDB_REPO}" ;
rdfs:label "${INSTANCE_DOMAIN}" ;
rep:repositoryImpl [
rep:repositoryType "graphdb:SailRepository" ;
sr:sailImpl [
sail:sailType "graphdb:Sail" ;
owlim:owlim-license "" ;
owlim:base-URL "http://${INSTANCE_DOMAIN}/" ;
owlim:defaultNS "" ;
owlim:entity-index-size "10000000" ;
owlim:entity-id-size "32" ;
owlim:imports "" ;
owlim:repository-type "file-repository" ;
owlim:ruleset "empty" ;
owlim:storage-folder "storage" ;
owlim:enable-context-index "false" ;
owlim:cache-memory "80m" ;
owlim:tuple-index-memory "80m" ;
owlim:enablePredicateList "false" ;
owlim:predicate-memory "0%" ;
owlim:fts-memory "0%" ;
owlim:ftsIndexPolicy "never" ;
owlim:ftsLiteralsOnly "true" ;
owlim:in-memory-literal-properties "false" ;
owlim:enable-literal-index "true" ;
owlim:index-compression-ratio "-1" ;
owlim:check-for-inconsistencies "false" ;
owlim:disable-sameAs "false" ;
owlim:enable-optimization "true" ;
owlim:transaction-mode "safe" ;
owlim:transaction-isolation "true" ;
owlim:query-timeout "0" ;
owlim:query-limit-results "0" ;
owlim:throw-QueryEvaluationException-on-timeout "false" ;
owlim:useShutdownHooks "true" ;
owlim:read-only "false" ;
owlim:nonInterpretablePredicates "http://www.w3.org/2000/01/rdf-schema#label;http://www.w3.org/1999/02/22-rdf-syntax-ns#type;http://www.ontotext.com/owlim/ces#gazetteerConfig;http://www.ontotext.com/owlim/ces#metadataConfig" ;
]
].

View file

@ -0,0 +1,136 @@
package triplestore
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"time"
"github.com/FAU-CDI/wisski-distillery/pkg/timex"
"github.com/pkg/errors"
"github.com/tkw1536/goprogram/stream"
)
type TriplestoreUserPayload struct {
Password string `json:"password"`
AppSettings TriplestoreUserAppSettings `json:"appSettings"`
GrantedAuthorities []string `json:"grantedAuthorities"`
}
type TriplestoreUserAppSettings struct {
DefaultInference bool `json:"DEFAULT_INFERENCE"`
DefaultVisGraphSchema bool `json:"DEFAULT_VIS_GRAPH_SCHEMA"`
DefaultSameas bool `json:"DEFAULT_SAMEAS"`
IgnoreSharedQueries bool `json:"IGNORE_SHARED_QUERIES"`
ExecuteCount bool `json:"EXECUTE_COUNT"`
}
// OpenRaw makes an http request to the triplestore api.
//
// When bodyName is non-empty, expect body to be a byte slice representing a multipart/form-data upload with the given name.
// When bodyName is empty, simply marshal body as application/json
func (ts Triplestore) OpenRaw(method, url string, body interface{}, bodyName string, accept string) (*http.Response, error) {
var reader io.Reader
var contentType string
// for "PUT" and "POST" we setup a body
if method == "PUT" || method == "POST" {
if bodyName != "" {
buffer := &bytes.Buffer{}
writer := multipart.NewWriter(buffer)
contentType = writer.FormDataContentType()
part, err := writer.CreateFormFile(bodyName, "filename.txt")
if err != nil {
return nil, err
}
io.Copy(part, bytes.NewReader(body.([]byte)))
writer.Close()
reader = buffer
} else {
contentType = "application/json"
mbytes, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(mbytes)
}
}
// create the request object
client := &http.Client{
Transport: &http.Transport{
DialContext: ts.Environment.DialContext,
DisableKeepAlives: true,
},
}
req, err := http.NewRequest(method, ts.BaseURL+url, reader)
if err != nil {
return nil, err
}
// Setup configuration!
if accept != "" {
req.Header.Set("Accept", accept)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.SetBasicAuth(ts.Config.TriplestoreAdminUser, ts.Config.TriplestoreAdminPassword)
// and send it
return client.Do(req)
}
// Wait waits for the connection to the Triplestore to succeed.
// This is achieved using a polling strategy.
func (ts Triplestore) Wait() error {
n := stream.FromNil()
return timex.TickUntilFunc(func(time.Time) bool {
res, err := ts.OpenRaw("GET", "/rest/repositories", nil, "", "")
n.EPrintf("[Triplestore.Wait]: %s\n", err)
if err != nil {
return false
}
defer res.Body.Close()
return true
}, ts.PollContext, ts.PollInterval)
}
// TriplestorePurgeUser deletes the specified user from the triplestore
func (ts Triplestore) PurgeUser(user string) error {
res, err := ts.OpenRaw("DELETE", "/rest/security/users/"+user, nil, "", "")
if err != nil {
return err
}
if res.StatusCode != http.StatusNoContent {
return errors.Errorf("Delete returned code %d", res.StatusCode)
}
return nil
}
// TriplestorePurgeRepo deletes the specified repo from the triplestore
func (ts Triplestore) PurgeRepo(repo string) error {
res, err := ts.OpenRaw("DELETE", "/rest/repositories/"+repo, nil, "", "")
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
return errors.Errorf("Delete returned code %d", res.StatusCode)
}
return nil
}
type Repository struct {
ID string `json:"id"`
Title string `json:"title"`
URI string `json:"uri"`
Type string `json:"type"`
SesameType string `json:"sesameType"`
Location string `json:"location"`
Readable bool `json:"readable"`
Writable bool `json:"writable"`
Local bool `json:"local"`
}

View file

@ -0,0 +1,88 @@
package triplestore
import (
"bytes"
"net/http"
_ "embed"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/pkg/errorx"
"github.com/FAU-CDI/wisski-distillery/pkg/unpack"
"github.com/tkw1536/goprogram/exit"
)
var errTripleStoreFailedRepository = exit.Error{
Message: "Failed to create repository: %s",
ExitCode: exit.ExitGeneric,
}
//go:embed create-repo.ttl
var createRepoTTL []byte
func (ts *Triplestore) Provision(instance models.Instance, domain string) error {
return ts.CreateRepository(instance.GraphDBRepository, domain, instance.GraphDBUsername, instance.GraphDBPassword)
}
func (ts *Triplestore) Purge(instance models.Instance, domain string) error {
return errorx.First(
ts.PurgeRepo(instance.GraphDBRepository),
ts.PurgeUser(instance.GraphDBUsername),
)
}
func (ts *Triplestore) CreateRepository(name, domain, user, password string) error {
if err := ts.Wait(); err != nil {
return err
}
// prepare the create repo request
var createRepo bytes.Buffer
err := unpack.WriteTemplate(&createRepo, map[string]string{
"GRAPHDB_REPO": name,
"INSTANCE_DOMAIN": domain,
}, bytes.NewReader(createRepoTTL))
if err != nil {
return err
}
// do the create!
{
res, err := ts.OpenRaw("POST", "/rest/repositories", createRepo.Bytes(), "config", "")
if err != nil {
return errTripleStoreFailedRepository.WithMessageF(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return errTripleStoreFailedRepository.WithMessageF("Repo create did not return status code 201")
}
}
// create the user and grant them access
{
res, err := ts.OpenRaw("POST", "/rest/security/users/"+user, TriplestoreUserPayload{
Password: password,
AppSettings: TriplestoreUserAppSettings{
DefaultInference: true,
DefaultVisGraphSchema: true,
DefaultSameas: true,
IgnoreSharedQueries: false,
ExecuteCount: true,
},
GrantedAuthorities: []string{
"ROLE_USER",
"READ_REPO_" + name,
"WRITE_REPO_" + name,
},
}, "", "")
if err != nil {
return errTripleStoreFailedRepository.WithMessageF(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return errTripleStoreFailedRepository.WithMessageF("User create did not return status code 201")
}
}
return nil
}

View file

@ -0,0 +1,38 @@
package triplestore
import (
"io"
"net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/pkg/errors"
)
func (Triplestore) SnapshotNeedsRunning() bool { return false }
func (Triplestore) SnapshotName() string { return "triplestore" }
func (ts *Triplestore) Snapshot(wisski models.Instance, context component.StagingContext) error {
return context.AddDirectory(".", func() error {
return context.AddFile(wisski.GraphDBRepository+".nq", func(file io.Writer) error {
_, err := ts.SnapshotDB(file, wisski.GraphDBRepository)
return err
})
})
}
var errTSBackupWrongStatusCode = errors.New("Triplestore.Backup: Wrong status code")
// SnapshotDB snapshots the provided repository into dst
func (ts Triplestore) SnapshotDB(dst io.Writer, repo string) (int64, error) {
res, err := ts.OpenRaw("GET", "/repositories/"+repo+"/statements?infer=false", nil, "", "application/n-quads")
if err != nil {
return 0, err
}
if res.StatusCode != http.StatusOK {
return 0, errTSBackupWrongStatusCode
}
defer res.Body.Close()
return io.Copy(dst, res.Body)
}

View file

@ -0,0 +1 @@
DOCKER_NETWORK_NAME=${DOCKER_NETWORK_NAME}

View file

@ -0,0 +1,52 @@
package triplestore
import (
"context"
"embed"
"path/filepath"
"time"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
)
type Triplestore struct {
component.Base
BaseURL string // upstream server url
PollContext context.Context // context to abort polling with
PollInterval time.Duration // duration to wait for during wait
}
func (ts *Triplestore) Path() string {
return filepath.Join(ts.Still.Config.DeployRoot, "core", "triplestore")
}
func (Triplestore) Context(parent component.InstallationContext) component.InstallationContext {
return parent
}
//go:embed all:triplestore
//go:embed triplestore.env
var resources embed.FS
func (ts *Triplestore) Stack(env environment.Environment) component.StackWithResources {
return component.MakeStack(ts, env, component.StackWithResources{
Resources: resources,
ContextPath: "triplestore",
CopyContextFiles: []string{"graphdb.zip"}, // TODO: Move into constant?
EnvPath: "triplestore.env",
EnvContext: map[string]string{
"DOCKER_NETWORK_NAME": ts.Config.DockerNetworkName,
},
MakeDirs: []string{
filepath.Join("data", "data"),
filepath.Join("data", "work"),
filepath.Join("data", "logs"),
},
})
}

Some files were not shown because too many files have changed in this diff Show more