Allow saving configuration from the first-time wizard

This just updates the config and saves it to `flamenco-manager.yaml`.

Saving the configuration doesn't restart the Manager yet, that's for
another commit.
This commit is contained in:
Sybren A. Stüvel 2022-07-14 17:27:17 +02:00
parent f9a3d3864a
commit 10f56148d4
6 changed files with 263 additions and 17 deletions

View File

@ -165,6 +165,9 @@ type ConfigService interface {
// ForceFirstRun forces IsFirstRun() to return true. This is used to force the
// first-time wizard on a configured system.
ForceFirstRun()
// Save writes the in-memory configuration to the config file.
Save() error
}
type Shaman interface {

View File

@ -11,6 +11,8 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"git.blender.org/flamenco/internal/appinfo"
"git.blender.org/flamenco/internal/find_blender"
@ -168,6 +170,7 @@ func (f *Flamenco) FindBlenderExePath(e echo.Context) error {
default:
response = append(response, api.BlenderPathCheckResult{
IsUsable: true,
Input: result.Input,
Path: result.FoundLocation,
Cause: result.BlenderVersion,
Source: result.Source,
@ -186,6 +189,7 @@ func (f *Flamenco) FindBlenderExePath(e echo.Context) error {
default:
response = append(response, api.BlenderPathCheckResult{
IsUsable: true,
Input: result.Input,
Path: result.FoundLocation,
Cause: result.BlenderVersion,
Source: result.Source,
@ -212,6 +216,7 @@ func (f *Flamenco) CheckBlenderExePath(e echo.Context) error {
ctx := e.Request().Context()
checkResult, err := find_blender.CheckBlender(ctx, command)
response := api.BlenderPathCheckResult{
Input: command,
Source: checkResult.Source,
}
@ -229,6 +234,67 @@ func (f *Flamenco) CheckBlenderExePath(e echo.Context) error {
return e.JSON(http.StatusOK, response)
}
func (f *Flamenco) SaveWizardConfig(e echo.Context) error {
logger := requestLogger(e)
var wizardCfg api.WizardConfig
if err := e.Bind(&wizardCfg); err != nil {
logger.Warn().Err(err).Msg("first-time wizard: bad request received")
return sendAPIError(e, http.StatusBadRequest, "invalid format")
}
logger = logger.With().Interface("config", wizardCfg).Logger()
if wizardCfg.StorageLocation == "" ||
!wizardCfg.BlenderExecutable.IsUsable ||
wizardCfg.BlenderExecutable.Path == "" {
logger.Warn().Msg("first-time wizard: configuration is incomplete, unable to accept")
return sendAPIError(e, http.StatusBadRequest, "configuration is incomplete")
}
conf := f.config.Get()
conf.SharedStoragePath = wizardCfg.StorageLocation
var executable string
switch wizardCfg.BlenderExecutable.Source {
case api.BlenderPathSourceFileAssociation:
// The Worker will try to use the file association when the command is set
// to the string "blender".
executable = "blender"
case api.BlenderPathSourcePathEnvvar:
// The input command can be found on $PATH, and thus we don't need to save
// the absolute path to Blender here.
executable = wizardCfg.BlenderExecutable.Input
case api.BlenderPathSourceInputPath:
// The path should be used as-is.
executable = wizardCfg.BlenderExecutable.Path
}
if commandNeedsQuoting(executable) {
executable = strconv.Quote(executable)
}
blenderCommand := fmt.Sprintf("%s %s", executable, config.DefaultBlenderArguments)
// Use the same command for each platform for now, but put them each in their
// own definition so that they're easier to edit later.
conf.Variables["blender"] = config.Variable{
IsTwoWay: false,
Values: config.VariableValues{
{Platform: "linux", Value: blenderCommand},
{Platform: "windows", Value: blenderCommand},
{Platform: "darwin", Value: blenderCommand},
},
}
// Save the final configuration to disk.
if err := f.config.Save(); err != nil {
logger.Error().Err(err).Msg("error saving configuration file")
return sendAPIError(e, http.StatusInternalServerError, "first-time wizard: error saving configuration file: %v", err)
}
logger.Info().Msg("first-time wizard: updating configuration")
return e.NoContent(http.StatusNoContent)
}
func flamencoManagerDir() (string, error) {
exename, err := os.Executable()
if err != nil {
@ -236,3 +302,7 @@ func flamencoManagerDir() (string, error) {
}
return filepath.Dir(exename), nil
}
func commandNeedsQuoting(cmd string) bool {
return strings.ContainsAny(cmd, " \n\t;()")
}

View File

@ -116,6 +116,102 @@ func TestCheckSharedStoragePath(t *testing.T) {
}
}
func TestSaveWizardConfig(t *testing.T) {
mf, finish := metaTestFixtures(t)
defer finish()
doTest := func(body api.WizardConfig) config.Conf {
// Always start the test with a clean configuration.
originalConfig := config.DefaultConfig(func(c *config.Conf) {
c.SharedStoragePath = ""
})
var savedConfig config.Conf
// Mock the loading & saving of the config.
mf.config.EXPECT().Get().Return(&originalConfig)
mf.config.EXPECT().Save().Do(func() error {
savedConfig = originalConfig
return nil
})
// Call the API.
echoCtx := mf.prepareMockedJSONRequest(body)
err := mf.flamenco.SaveWizardConfig(echoCtx)
if !assert.NoError(t, err) {
t.FailNow()
}
assertResponseNoContent(t, echoCtx)
return savedConfig
}
// Test situation where file association with .blend files resulted in a blender executable.
{
savedConfig := doTest(api.WizardConfig{
StorageLocation: mf.tempdir,
BlenderExecutable: api.BlenderPathCheckResult{
IsUsable: true,
Input: "",
Path: "/path/to/blender",
Source: api.BlenderPathSourceFileAssociation,
},
})
assert.Equal(t, mf.tempdir, savedConfig.SharedStoragePath)
expectBlenderVar := config.Variable{
Values: config.VariableValues{
{Platform: "linux", Value: "blender " + config.DefaultBlenderArguments},
{Platform: "windows", Value: "blender " + config.DefaultBlenderArguments},
{Platform: "darwin", Value: "blender " + config.DefaultBlenderArguments},
},
}
assert.Equal(t, expectBlenderVar, savedConfig.Variables["blender"])
}
// Test situation where the given command could be found on $PATH.
{
savedConfig := doTest(api.WizardConfig{
StorageLocation: mf.tempdir,
BlenderExecutable: api.BlenderPathCheckResult{
IsUsable: true,
Input: "kitty",
Path: "/path/to/kitty",
Source: api.BlenderPathSourcePathEnvvar,
},
})
assert.Equal(t, mf.tempdir, savedConfig.SharedStoragePath)
expectBlenderVar := config.Variable{
Values: config.VariableValues{
{Platform: "linux", Value: "kitty " + config.DefaultBlenderArguments},
{Platform: "windows", Value: "kitty " + config.DefaultBlenderArguments},
{Platform: "darwin", Value: "kitty " + config.DefaultBlenderArguments},
},
}
assert.Equal(t, expectBlenderVar, savedConfig.Variables["blender"])
}
// Test a custom command given with the full path.
{
savedConfig := doTest(api.WizardConfig{
StorageLocation: mf.tempdir,
BlenderExecutable: api.BlenderPathCheckResult{
IsUsable: true,
Input: "/bin/cat",
Path: "/bin/cat",
Source: api.BlenderPathSourceInputPath,
},
})
assert.Equal(t, mf.tempdir, savedConfig.SharedStoragePath)
expectBlenderVar := config.Variable{
Values: config.VariableValues{
{Platform: "linux", Value: "/bin/cat " + config.DefaultBlenderArguments},
{Platform: "windows", Value: "/bin/cat " + config.DefaultBlenderArguments},
{Platform: "darwin", Value: "/bin/cat " + config.DefaultBlenderArguments},
},
}
assert.Equal(t, expectBlenderVar, savedConfig.Variables["blender"])
}
}
func metaTestFixtures(t *testing.T) (mockedFlamenco, func()) {
mockCtrl := gomock.NewController(t)
mf := newMockedFlamenco(mockCtrl)

View File

@ -754,6 +754,20 @@ func (mr *MockConfigServiceMockRecorder) ResolveVariables(arg0, arg1 interface{}
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveVariables", reflect.TypeOf((*MockConfigService)(nil).ResolveVariables), arg0, arg1)
}
// Save mocks base method.
func (m *MockConfigService) Save() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Save")
ret0, _ := ret[0].(error)
return ret0
}
// Save indicates an expected call of Save.
func (mr *MockConfigServiceMockRecorder) Save() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockConfigService)(nil).Save))
}
// MockTaskStateMachine is a mock of TaskStateMachine interface.
type MockTaskStateMachine struct {
ctrl *gomock.Controller

View File

@ -9,6 +9,8 @@ import (
// SPDX-License-Identifier: GPL-3.0-or-later
const DefaultBlenderArguments = "--factory-startup -b -y"
// The default configuration, use DefaultConfig() to obtain a copy.
var defaultConfig = Conf{
Base: Base{
@ -61,9 +63,9 @@ var defaultConfig = Conf{
// The default commands assume that the executables are available on $PATH.
"blender": {
Values: VariableValues{
VariableValue{Platform: "linux", Value: "blender --factory-startup -b -y"},
VariableValue{Platform: "windows", Value: "blender.exe --factory-startup -b -y"},
VariableValue{Platform: "darwin", Value: "blender --factory-startup -b -y"},
VariableValue{Platform: "linux", Value: "blender " + DefaultBlenderArguments},
VariableValue{Platform: "windows", Value: "blender.exe " + DefaultBlenderArguments},
VariableValue{Platform: "darwin", Value: "blender " + DefaultBlenderArguments},
},
},
"ffmpeg": {

View File

@ -3,8 +3,8 @@
<h1>Welcome to Flamenco!</h1>
<section>
<p>Before Flamenco can be used, a few things need to be set up.</p>
<p>This wizard will guide you through the configuration.</p>
<p>Before Flamenco can be used, a few things need to be set up. This
wizard will guide you through the configuration.</p>
</section>
<section>
<h2>Shared Storage</h2>
@ -13,10 +13,15 @@
Manager and Workers exchange files. This could be a NAS in your network,
or some other file sharing server.</p>
<p>Make sure this path is the same for all machines involved.</p>
<p class="hint">Using a service like Syncthing, ownCloud, or Dropbox for
this is not recommended, as Flamenco does not know when every machine has
received the files.</p>
<!-- TODO: @submit.prevent makes the button triggerable by pressing ENTER
in the input field, but also prevents the browser from caching
previously-used values. Would be great if we could have both. -->
<form @submit.prevent="checkSharedStoragePath">
<input v-model="sharedStoragePath" type="text">
<button type="submit">Check</button>
@ -33,7 +38,7 @@
<p>Choose which Blender to use below:</p>
<p v-if="blenderExeFinding">... finding Blenders ...</p>
<p v-if="isBlenderExeFinding">... finding Blenders ...</p>
<div v-for="blender in allBlenders" class="blender-selector"
:class="{ 'selected-blender': (blender == selectedBlender) }">
<dl>
@ -46,7 +51,7 @@
<dt>Source</dt>
<dd>{{ sourceLabels[blender.source] }}</dd>
</dl>
<button @click="selectedBlender = blender">Use this Blender</button>
<button @click="selectedBlender = blender" :disabled="selectedBlender == blender">Use this Blender</button>
</div>
<p>Or provide an alternative command to try:</p>
@ -55,12 +60,35 @@
<input v-model="customBlenderExe" type="text">
<button type="submit">Check</button>
</form>
<p v-if="blenderExeChecking">... checking ...</p>
<p v-if="isBlenderExeChecking">... checking ...</p>
<p v-if="blenderExeCheckResult != null && blenderExeCheckResult.is_usable" class="check-ok">
Found something, it is selected above.</p>
<p v-if="blenderExeCheckResult != null && !blenderExeCheckResult.is_usable" class="check-failed">
{{ blenderExeCheckResult.cause }}</p>
</section>
<section v-if="isConfigComplete">
<h2>The Final Step</h2>
<p>This is the configuration that will be used by Flamenco:</p>
<dl>
<dt>Storage</dt>
<dd>{{ sharedStorageCheckResult.path }}</dd>
<dt>Blender</dt>
<dd v-if="selectedBlender.source == 'file_association'">
Whatever Blender is associated with .blend files
(currently "<code>{{ selectedBlender.path }}</code>")
</dd>
<dd v-if="selectedBlender.source == 'path_envvar'">
The command "<code>{{ selectedBlender.input }}</code>" as found on <code>$PATH</code>
(currently "<code>{{ selectedBlender.path }}</code>")
</dd>
<dd v-if="selectedBlender.source == 'input_path'">
The command you provided:
"<code>{{ selectedBlender.path }}</code>"
</dd>
</dl>
<button @click="confirmWizard" :disabled="isConfirming">Confirm</button>
</section>
</div>
<footer class="app-footer">
@ -73,7 +101,7 @@
<script>
import NotificationBar from '@/components/footer/NotificationBar.vue'
import UpdateListener from '@/components/UpdateListener.vue'
import { MetaApi, PathCheckInput } from "@/manager-api";
import { MetaApi, PathCheckInput, WizardConfig } from "@/manager-api";
import { apiClient } from '@/stores/api-query-count';
export default {
@ -90,17 +118,18 @@ export default {
allBlenders: [], // combination of autoFoundBlenders and blenderExeCheckResult.
autoFoundBlenders: [], // list of api.BlenderPathCheckResult
blenderExeFinding: false,
isBlenderExeFinding: false,
selectedBlender: null, // the chosen api.BlenderPathCheckResult
customBlenderExe: "",
blenderExeChecking: false,
isBlenderExeChecking: false,
blenderExeCheckResult: null, // api.BlenderPathCheckResult
sourceLabels: {
file_association: "This Blender runs when you double-click a .blend file.",
path_envvar: "This Blender was found on the $PATH environment.",
input_path: "You pointed Flamenco to this executable.",
}
},
isConfirming: false,
}),
computed: {
cleanSharedStoragePath() {
@ -109,6 +138,10 @@ export default {
cleanCustomBlenderExe() {
return this.customBlenderExe.trim();
},
isConfigComplete() {
return (this.sharedStorageCheckResult != null && this.sharedStorageCheckResult.is_usable) &&
(this.selectedBlender != null && this.selectedBlender.is_usable);
},
},
mounted() {
this.findBlenderExePath();
@ -135,7 +168,7 @@ export default {
},
findBlenderExePath() {
this.blenderExeFinding = true;
this.isBlenderExeFinding = true;
this.autoFoundBlenders = [];
console.log("Finding Blender");
@ -149,15 +182,24 @@ export default {
console.log("Error finding Blender:", error);
})
.finally(() => {
this.blenderExeFinding = false;
this.isBlenderExeFinding = false;
})
},
checkBlenderExePath() {
this.blenderExeChecking = true;
const exeToTry = this.cleanCustomBlenderExe;
if (exeToTry == "") {
// Just erase any previously-found custom Blender executable.
this.isBlenderExeChecking = false;
this.blenderExeCheckResult = null;
this._refreshAllBlenders();
return;
}
this.isBlenderExeChecking = true;
this.blenderExeCheckResult = null;
const pathCheck = new PathCheckInput(this.cleanCustomBlenderExe);
const pathCheck = new PathCheckInput(exeToTry);
console.log("requesting path check:", pathCheck);
this.metaAPI.checkBlenderExePath({ pathCheckInput: pathCheck })
.then((result) => {
@ -172,7 +214,7 @@ export default {
console.log("Error checking storage path:", error);
})
.finally(() => {
this.blenderExeChecking = false;
this.isBlenderExeChecking = false;
})
},
@ -183,6 +225,25 @@ export default {
this.allBlenders = this.autoFoundBlenders.concat([this.blenderExeCheckResult]);
}
},
confirmWizard() {
const wizardConfig = new WizardConfig(
this.sharedStorageCheckResult.path,
this.selectedBlender,
);
console.log("saving configuration:", wizardConfig);
this.isConfirming = true;
this.metaAPI.saveWizardConfig({ wizardConfig: wizardConfig })
.then((result) => {
console.log("Wizard config saved, reload the page");
})
.catch((error) => {
console.log("Error saving wizard config:", error);
})
.finally(() => {
this.isConfirming = false;
})
},
},
}
</script>