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 // ForceFirstRun forces IsFirstRun() to return true. This is used to force the
// first-time wizard on a configured system. // first-time wizard on a configured system.
ForceFirstRun() ForceFirstRun()
// Save writes the in-memory configuration to the config file.
Save() error
} }
type Shaman interface { type Shaman interface {

View File

@ -11,6 +11,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings"
"git.blender.org/flamenco/internal/appinfo" "git.blender.org/flamenco/internal/appinfo"
"git.blender.org/flamenco/internal/find_blender" "git.blender.org/flamenco/internal/find_blender"
@ -168,6 +170,7 @@ func (f *Flamenco) FindBlenderExePath(e echo.Context) error {
default: default:
response = append(response, api.BlenderPathCheckResult{ response = append(response, api.BlenderPathCheckResult{
IsUsable: true, IsUsable: true,
Input: result.Input,
Path: result.FoundLocation, Path: result.FoundLocation,
Cause: result.BlenderVersion, Cause: result.BlenderVersion,
Source: result.Source, Source: result.Source,
@ -186,6 +189,7 @@ func (f *Flamenco) FindBlenderExePath(e echo.Context) error {
default: default:
response = append(response, api.BlenderPathCheckResult{ response = append(response, api.BlenderPathCheckResult{
IsUsable: true, IsUsable: true,
Input: result.Input,
Path: result.FoundLocation, Path: result.FoundLocation,
Cause: result.BlenderVersion, Cause: result.BlenderVersion,
Source: result.Source, Source: result.Source,
@ -212,6 +216,7 @@ func (f *Flamenco) CheckBlenderExePath(e echo.Context) error {
ctx := e.Request().Context() ctx := e.Request().Context()
checkResult, err := find_blender.CheckBlender(ctx, command) checkResult, err := find_blender.CheckBlender(ctx, command)
response := api.BlenderPathCheckResult{ response := api.BlenderPathCheckResult{
Input: command,
Source: checkResult.Source, Source: checkResult.Source,
} }
@ -229,6 +234,67 @@ func (f *Flamenco) CheckBlenderExePath(e echo.Context) error {
return e.JSON(http.StatusOK, response) 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) { func flamencoManagerDir() (string, error) {
exename, err := os.Executable() exename, err := os.Executable()
if err != nil { if err != nil {
@ -236,3 +302,7 @@ func flamencoManagerDir() (string, error) {
} }
return filepath.Dir(exename), nil 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()) { func metaTestFixtures(t *testing.T) (mockedFlamenco, func()) {
mockCtrl := gomock.NewController(t) mockCtrl := gomock.NewController(t)
mf := newMockedFlamenco(mockCtrl) 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) 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. // MockTaskStateMachine is a mock of TaskStateMachine interface.
type MockTaskStateMachine struct { type MockTaskStateMachine struct {
ctrl *gomock.Controller ctrl *gomock.Controller

View File

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

View File

@ -3,8 +3,8 @@
<h1>Welcome to Flamenco!</h1> <h1>Welcome to Flamenco!</h1>
<section> <section>
<p>Before Flamenco can be used, a few things need to be set up.</p> <p>Before Flamenco can be used, a few things need to be set up. This
<p>This wizard will guide you through the configuration.</p> wizard will guide you through the configuration.</p>
</section> </section>
<section> <section>
<h2>Shared Storage</h2> <h2>Shared Storage</h2>
@ -13,10 +13,15 @@
Manager and Workers exchange files. This could be a NAS in your network, Manager and Workers exchange files. This could be a NAS in your network,
or some other file sharing server.</p> 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 <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 this is not recommended, as Flamenco does not know when every machine has
received the files.</p> 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"> <form @submit.prevent="checkSharedStoragePath">
<input v-model="sharedStoragePath" type="text"> <input v-model="sharedStoragePath" type="text">
<button type="submit">Check</button> <button type="submit">Check</button>
@ -33,7 +38,7 @@
<p>Choose which Blender to use below:</p> <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" <div v-for="blender in allBlenders" class="blender-selector"
:class="{ 'selected-blender': (blender == selectedBlender) }"> :class="{ 'selected-blender': (blender == selectedBlender) }">
<dl> <dl>
@ -46,7 +51,7 @@
<dt>Source</dt> <dt>Source</dt>
<dd>{{ sourceLabels[blender.source] }}</dd> <dd>{{ sourceLabels[blender.source] }}</dd>
</dl> </dl>
<button @click="selectedBlender = blender">Use this Blender</button> <button @click="selectedBlender = blender" :disabled="selectedBlender == blender">Use this Blender</button>
</div> </div>
<p>Or provide an alternative command to try:</p> <p>Or provide an alternative command to try:</p>
@ -55,12 +60,35 @@
<input v-model="customBlenderExe" type="text"> <input v-model="customBlenderExe" type="text">
<button type="submit">Check</button> <button type="submit">Check</button>
</form> </form>
<p v-if="blenderExeChecking">... checking ...</p> <p v-if="isBlenderExeChecking">... checking ...</p>
<p v-if="blenderExeCheckResult != null && blenderExeCheckResult.is_usable" class="check-ok"> <p v-if="blenderExeCheckResult != null && blenderExeCheckResult.is_usable" class="check-ok">
Found something, it is selected above.</p> Found something, it is selected above.</p>
<p v-if="blenderExeCheckResult != null && !blenderExeCheckResult.is_usable" class="check-failed"> <p v-if="blenderExeCheckResult != null && !blenderExeCheckResult.is_usable" class="check-failed">
{{ blenderExeCheckResult.cause }}</p> {{ blenderExeCheckResult.cause }}</p>
</section> </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> </div>
<footer class="app-footer"> <footer class="app-footer">
@ -73,7 +101,7 @@
<script> <script>
import NotificationBar from '@/components/footer/NotificationBar.vue' import NotificationBar from '@/components/footer/NotificationBar.vue'
import UpdateListener from '@/components/UpdateListener.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'; import { apiClient } from '@/stores/api-query-count';
export default { export default {
@ -90,17 +118,18 @@ export default {
allBlenders: [], // combination of autoFoundBlenders and blenderExeCheckResult. allBlenders: [], // combination of autoFoundBlenders and blenderExeCheckResult.
autoFoundBlenders: [], // list of api.BlenderPathCheckResult autoFoundBlenders: [], // list of api.BlenderPathCheckResult
blenderExeFinding: false, isBlenderExeFinding: false,
selectedBlender: null, // the chosen api.BlenderPathCheckResult selectedBlender: null, // the chosen api.BlenderPathCheckResult
customBlenderExe: "", customBlenderExe: "",
blenderExeChecking: false, isBlenderExeChecking: false,
blenderExeCheckResult: null, // api.BlenderPathCheckResult blenderExeCheckResult: null, // api.BlenderPathCheckResult
sourceLabels: { sourceLabels: {
file_association: "This Blender runs when you double-click a .blend file.", file_association: "This Blender runs when you double-click a .blend file.",
path_envvar: "This Blender was found on the $PATH environment.", path_envvar: "This Blender was found on the $PATH environment.",
input_path: "You pointed Flamenco to this executable.", input_path: "You pointed Flamenco to this executable.",
} },
isConfirming: false,
}), }),
computed: { computed: {
cleanSharedStoragePath() { cleanSharedStoragePath() {
@ -109,6 +138,10 @@ export default {
cleanCustomBlenderExe() { cleanCustomBlenderExe() {
return this.customBlenderExe.trim(); return this.customBlenderExe.trim();
}, },
isConfigComplete() {
return (this.sharedStorageCheckResult != null && this.sharedStorageCheckResult.is_usable) &&
(this.selectedBlender != null && this.selectedBlender.is_usable);
},
}, },
mounted() { mounted() {
this.findBlenderExePath(); this.findBlenderExePath();
@ -135,7 +168,7 @@ export default {
}, },
findBlenderExePath() { findBlenderExePath() {
this.blenderExeFinding = true; this.isBlenderExeFinding = true;
this.autoFoundBlenders = []; this.autoFoundBlenders = [];
console.log("Finding Blender"); console.log("Finding Blender");
@ -149,15 +182,24 @@ export default {
console.log("Error finding Blender:", error); console.log("Error finding Blender:", error);
}) })
.finally(() => { .finally(() => {
this.blenderExeFinding = false; this.isBlenderExeFinding = false;
}) })
}, },
checkBlenderExePath() { 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; this.blenderExeCheckResult = null;
const pathCheck = new PathCheckInput(this.cleanCustomBlenderExe); const pathCheck = new PathCheckInput(exeToTry);
console.log("requesting path check:", pathCheck); console.log("requesting path check:", pathCheck);
this.metaAPI.checkBlenderExePath({ pathCheckInput: pathCheck }) this.metaAPI.checkBlenderExePath({ pathCheckInput: pathCheck })
.then((result) => { .then((result) => {
@ -172,7 +214,7 @@ export default {
console.log("Error checking storage path:", error); console.log("Error checking storage path:", error);
}) })
.finally(() => { .finally(() => {
this.blenderExeChecking = false; this.isBlenderExeChecking = false;
}) })
}, },
@ -183,6 +225,25 @@ export default {
this.allBlenders = this.autoFoundBlenders.concat([this.blenderExeCheckResult]); 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> </script>