diff --git a/.vscode/launch.json b/.vscode/launch.json index d1af61246..3d162f605 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,7 +13,7 @@ "args": [ "publish-ui", "-n", - "devpwpg", + "dogfood", "${workspaceFolder}/notebook1" ], }, @@ -26,7 +26,7 @@ "args": [ "publish", "-n", - "badauth2", + "dogfood", "${workspaceFolder}/notebook1", "--debug", ], diff --git a/cmd/connect-client/commands/publish.go b/cmd/connect-client/commands/publish.go index bc9bfdde2..d1d99b29b 100644 --- a/cmd/connect-client/commands/publish.go +++ b/cmd/connect-client/commands/publish.go @@ -7,6 +7,7 @@ import ( "io/fs" "os" + "github.com/r3labs/sse/v2" "github.com/rstudio/connect-client/internal/apptypes" "github.com/rstudio/connect-client/internal/bundles" "github.com/rstudio/connect-client/internal/bundles/gitignore" @@ -215,7 +216,8 @@ func (cmd *CreateBundleCmd) Run(args *cli_types.CommonArgs, ctx *cli_types.CLICo if err != nil { return err } - return publish.CreateBundleFromDirectory(&cmd.PublishArgs, cmd.BundleFile, ctx.Logger) + publisher := publish.New(&cmd.PublishArgs) + return publisher.CreateBundleFromDirectory(cmd.BundleFile, ctx.Logger) } type WriteManifestCmd struct { @@ -227,7 +229,8 @@ func (cmd *WriteManifestCmd) Run(args *cli_types.CommonArgs, ctx *cli_types.CLIC if err != nil { return err } - return publish.WriteManifestFromDirectory(&cmd.PublishArgs, ctx.Logger) + publisher := publish.New(&cmd.PublishArgs) + return publisher.WriteManifestFromDirectory(ctx.Logger) } type PublishCmd struct { @@ -239,7 +242,8 @@ func (cmd *PublishCmd) Run(args *cli_types.CommonArgs, ctx *cli_types.CLIContext if err != nil { return err } - return publish.PublishDirectory(&cmd.PublishArgs, ctx.Accounts, ctx.Logger) + publisher := publish.New(&cmd.PublishArgs) + return publisher.PublishDirectory(ctx.Accounts, ctx.Logger) } type PublishUICmd struct { @@ -252,7 +256,10 @@ func (cmd *PublishUICmd) Run(args *cli_types.CommonArgs, ctx *cli_types.CLIConte if err != nil { return err } - log := events.NewLoggerWithSSE(args.Debug) + eventServer := sse.New() + eventServer.CreateStream("messages") + + log := events.NewLoggerWithSSE(args.Debug, eventServer) svc := ui.NewUIService( "/", cmd.UIArgs, @@ -260,6 +267,7 @@ func (cmd *PublishUICmd) Run(args *cli_types.CommonArgs, ctx *cli_types.CLIConte ctx.LocalToken, ctx.Fs, ctx.Accounts, - log) + log, + eventServer) return svc.Run() } diff --git a/internal/api_client/clients/client_connect.go b/internal/api_client/clients/client_connect.go index b447c2ef6..031a8b621 100644 --- a/internal/api_client/clients/client_connect.go +++ b/internal/api_client/clients/client_connect.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "regexp" + "strings" "time" "github.com/rstudio/connect-client/internal/accounts" @@ -241,19 +242,117 @@ func (c *ConnectClient) getTask(taskID types.TaskID, previous *taskDTO) (*taskDT return &task, nil } +var buildRPattern = regexp.MustCompile("Building (Shiny application|Plumber API|R Markdown document).*") +var buildPythonPattern = regexp.MustCompile("Building (.* application|.* API|Jupyter notebook).*") +var launchPattern = regexp.MustCompile("Launching .* (application|API|notebook)") +var staticPattern = regexp.MustCompile("(Building|Launching) static content") + func eventOpFromLogLine(currentOp events.Operation, line string) events.Operation { - if match, _ := regexp.MatchString("Building (Shiny application|Plumber API).*", line); match { + match := buildRPattern.MatchString(line) + if match || strings.Contains(line, "Bundle created with R version") { return events.PublishRestoreREnvOp - } else if match, _ := regexp.MatchString("Building (.* application|.* API|Jupyter notebook).*", line); match { + } + match = buildPythonPattern.MatchString(line) + if match || strings.Contains(line, "Bundle requested Python version") { return events.PublishRestorePythonEnvOp - } else if match, _ := regexp.MatchString("Launching .* (application|API|notebook)", line); match { + } + match = launchPattern.MatchString(line) + if match { return events.PublishRunContentOp - } else if match, _ := regexp.MatchString("(Building|Launching) static content", line); match { + } + match = staticPattern.MatchString(line) + if match { return events.PublishRunContentOp } return currentOp } +type packageRuntime string + +const ( + rRuntime packageRuntime = "r" + pythonRuntime packageRuntime = "python" +) + +type packageStatus string + +const ( + downloadAndInstallPackage packageStatus = "download+install" + downloadPackage packageStatus = "download" + installPackage packageStatus = "install" +) + +var rPackagePattern = regexp.MustCompile(`Installing ([[:word:]\.]+) \((\S+)\) ...`) +var pythonCollectingPackagePattern = regexp.MustCompile(`Collecting (\S+)==(\S+)`) +var pythonInstallingPackagePattern = regexp.MustCompile(`Found existing installation: (\S+) ()\S+`) + +type packageEvent struct { + runtime packageRuntime + status packageStatus + name string + version string +} + +func makePackageEvent(match []string, rt packageRuntime, status packageStatus) *packageEvent { + return &packageEvent{ + runtime: rt, + status: status, + name: match[1], + version: match[2], + } +} + +func packageEventFromLogLine(line string) *packageEvent { + if match := rPackagePattern.FindStringSubmatch(line); match != nil { + return makePackageEvent(match, rRuntime, downloadAndInstallPackage) + } else if match := pythonCollectingPackagePattern.FindStringSubmatch(line); match != nil { + return makePackageEvent(match, pythonRuntime, downloadPackage) + } else if match := pythonInstallingPackagePattern.FindStringSubmatch(line); match != nil { + return makePackageEvent(match, pythonRuntime, installPackage) + } + return nil +} + +func handleTaskUpdate(task *taskDTO, op types.Operation, log logging.Logger) (types.Operation, error) { + var nextOp types.Operation + + for _, line := range task.Output { + // Detect state transitions from certain matching log lines. + nextOp = eventOpFromLogLine(op, line) + if nextOp != op { + if op != "" { + log.Success("Done", logging.LogKeyOp, op) + } + op = nextOp + log.Start(line, logging.LogKeyOp, op) + } else { + log.Info(line, logging.LogKeyOp, op) + } + + // Log a progress event for certain matching log lines. + event := packageEventFromLogLine(line) + if event != nil { + log.Status("Package restore", + "runtime", event.runtime, + "status", event.status, + "name", event.name, + "version", event.version, + logging.LogKeyOp, op) + } + } + if task.Finished { + if task.Error != "" { + // TODO: make these errors more specific, maybe by + // using the Connect error codes from the logs. + err := types.NewAgentError(events.DeploymentFailedCode, errors.New(task.Error), nil) + err.SetOperation(op) + return op, err + } + log.Success("Done", logging.LogKeyOp, op) + } + return op, nil +} + func (c *ConnectClient) WaitForTask(taskID types.TaskID, log logging.Logger) error { var previous *taskDTO var op events.Operation @@ -263,26 +362,9 @@ func (c *ConnectClient) WaitForTask(taskID types.TaskID, log logging.Logger) err if err != nil { return err } - for _, line := range task.Output { - nextOp := eventOpFromLogLine(op, line) - if nextOp != op { - if op != "" { - log.Success("Done") - } - op = nextOp - log = log.With(logging.LogKeyOp, op) - } - log.Info(line) - } - if task.Finished { - if task.Error != "" { - // TODO: make these errors more specific, maybe by - // using the Connect error codes from the logs. - err := errors.New(task.Error) - return types.NewAgentError(events.DeploymentFailedCode, err, nil) - } - log.Success("Done") - return nil + op, err = handleTaskUpdate(task, op, log) + if err != nil || task.Finished { + return err } previous = task time.Sleep(500 * time.Millisecond) diff --git a/internal/api_client/clients/client_connect_test.go b/internal/api_client/clients/client_connect_test.go new file mode 100644 index 000000000..9f2d074d6 --- /dev/null +++ b/internal/api_client/clients/client_connect_test.go @@ -0,0 +1,327 @@ +package clients + +// Copyright (C) 2023 by Posit Software, PBC. + +import ( + "errors" + "io/fs" + "testing" + "time" + + "github.com/rstudio/connect-client/internal/accounts" + "github.com/rstudio/connect-client/internal/events" + "github.com/rstudio/connect-client/internal/logging" + "github.com/rstudio/connect-client/internal/logging/loggingtest" + "github.com/rstudio/connect-client/internal/types" + "github.com/rstudio/connect-client/internal/util/utiltest" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +type ConnectClientSuite struct { + utiltest.Suite +} + +func TestConnectClientSuite(t *testing.T) { + suite.Run(t, new(ConnectClientSuite)) +} + +func (s *ConnectClientSuite) TestNewConnectClient() { + account := &accounts.Account{} + timeout := 10 * time.Second + log := logging.New() + + client, err := NewConnectClient(account, timeout, log) + s.NoError(err) + s.Equal(account, client.account) + s.Equal(log, client.log) + s.NotNil(client.client) +} + +func (s *ConnectClientSuite) TestNewConnectClientErr() { + account := &accounts.Account{ + Certificate: "/nonexistent", + } + timeout := 10 * time.Second + log := logging.New() + + client, err := NewConnectClient(account, timeout, log) + s.ErrorIs(err, fs.ErrNotExist) + s.Nil(client) +} + +type taskTest struct { + task taskDTO // The task response from the server + nextOp types.Operation // Expected next state + err error // Expected error +} + +func (s *ConnectClientSuite) TestWaitForTask() { + log := loggingtest.NewMockLogger() + + str := mock.AnythingOfType("string") + anything := mock.Anything + log.On("Start", "Building Jupyter notebook...", logging.LogKeyOp, events.PublishRestorePythonEnvOp) + log.On("Start", "Launching Jupyter notebook...", logging.LogKeyOp, events.PublishRunContentOp) + log.On("Start", "Bundle created with R version 4.3.0, Python version 3.11.3, and Quarto version 0.9.105 is compatible with environment Local with R version 4.3.1 from /opt/R/4.3.1/bin/R, Python version 3.11.3 from /opt/python/3.11.3/bin/python3.11, and Quarto version 1.3.450 from /opt/quarto/1.3.450/bin/quarto", logging.LogKeyOp, events.PublishRestoreREnvOp) + log.On("Success", "Done", logging.LogKeyOp, events.PublishRestorePythonEnvOp) + log.On("Success", "Done", logging.LogKeyOp, events.PublishRunContentOp) + log.On("Success", "Done", logging.LogKeyOp, events.AgentOp) + log.On("Info", str, str, anything) + + expectedPackages := []struct { + rt packageRuntime + status packageStatus + name string + version string + }{ + {pythonRuntime, installPackage, "wheel", ""}, + {pythonRuntime, installPackage, "setuptools", ""}, + {pythonRuntime, installPackage, "pip", ""}, + {pythonRuntime, downloadPackage, "anyio", "3.6.2"}, + {pythonRuntime, downloadPackage, "argon2-cffi", "21.3.0"}, + {rRuntime, downloadAndInstallPackage, "R6", "2.5.1"}, + {rRuntime, downloadAndInstallPackage, "Rcpp", "1.0.10"}, + } + for _, pkg := range expectedPackages { + op := events.PublishRestorePythonEnvOp + if pkg.rt == rRuntime { + op = events.PublishRestoreREnvOp + } + log.On("Status", + "Package restore", + "runtime", pkg.rt, + "status", pkg.status, + "name", pkg.name, + "version", pkg.version, + logging.LogKeyOp, op) + } + taskID := types.TaskID("W3YpnrwUOQJxL5DS") + + tests := []taskTest{ + { + task: taskDTO{ + Id: taskID, + Output: []string{ + "Building Jupyter notebook...", + "Bundle created with Python version 3.11.3 is compatible with environment Local with Python version 3.11.3 from /opt/python/3.11.3/bin/python3.11", + "Bundle requested Python version 3.11.3; using /opt/python/3.11.3/bin/python3.11 which has version 3.11.3", + "2023/09/12 12:11:28.545506553 [rsc-session] Content GUID: 04f24082-2396-484b-9839-f810891dce95", + "2023/09/12 12:11:28.545544671 [rsc-session] Content ID: 24257", + "2023/09/12 12:11:28.545552006 [rsc-session] Bundle ID: 41367", + "2023/09/12 12:11:28.545557386 [rsc-session] Job Key: W3YpnrwUOQJxL5DS ", + "2023/09/12 12:11:28.687716230 Linux distribution: Ubuntu 22.04.2 LTS (jammy)", + "2023/09/12 12:11:28.689253595 Running as user: uid=1031(rstudio-connect) gid=999(rstudio-connect) groups=999(rstudio-connect)", + "2023/09/12 12:11:28.689266827 Connect version: 2023.08.0-dev+835", + "2023/09/12 12:11:28.689299912 LANG: C.UTF-8", + "2023/09/12 12:11:28.689301087 Working directory: /opt/rstudio-connect/mnt/app ", + }, + Last: 11, + }, + nextOp: events.PublishRestorePythonEnvOp, + }, + { + task: taskDTO{ + Id: taskID, + Output: []string{ + "2023/09/12 12:11:28.689309426 Building environment using Python 3.11.3 (main, Jun 4 2023, 22:34:28) [GCC 11.3.0] at /opt/python/3.11.3/bin/python3.11", + "2023/09/12 12:11:28.704289342 Skipped packages: appnope==0.1.3", + "2023/09/12 12:11:28.704310687 Creating environment: AbrY5VfQZ5r97HDk5puHtA", + "2023/09/12 12:11:46.700864736 Requirement already satisfied: pip in ./python/env/lib/python3.11/site-packages (22.3.1)", + "2023/09/12 12:11:46.985722400 Collecting pip", + "2023/09/12 12:11:47.051703423 Using cached pip-23.2.1-py3-none-any.whl (2.1 MB)", + "2023/09/12 12:11:47.069280700 Requirement already satisfied: setuptools in ./python/env/lib/python3.11/site-packages (65.5.0)", + "2023/09/12 12:11:47.410791493 Collecting setuptools", + "2023/09/12 12:11:47.425842304 Using cached setuptools-68.2.1-py3-none-any.whl (807 kB)", + "2023/09/12 12:11:47.433729649 Requirement already satisfied: wheel in /opt/python/3.11.3/lib/python3.11/site-packages (0.41.1)", + "2023/09/12 12:11:47.487214762 Collecting wheel", + "2023/09/12 12:11:47.496886789 Using cached wheel-0.41.2-py3-none-any.whl (64 kB)", + "2023/09/12 12:11:47.648418967 Installing collected packages: wheel, setuptools, pip", + "2023/09/12 12:11:47.648650446 Attempting uninstall: wheel", + "2023/09/12 12:11:47.652986893 Found existing installation: wheel 0.41.1", + "2023/09/12 12:11:47.653521318 Not uninstalling wheel at /opt/python/3.11.3/lib/python3.11/site-packages, outside environment /opt/rstudio-connect/mnt/app/python/env", + "2023/09/12 12:11:47.653782046 Can't uninstall 'wheel'. No files were found to uninstall.", + "2023/09/12 12:11:48.144296856 Attempting uninstall: setuptools", + "2023/09/12 12:11:48.149444749 Found existing installation: setuptools 65.5.0", + "2023/09/12 12:11:48.550304512 Uninstalling setuptools-65.5.0:", + "2023/09/12 12:11:48.944303947 Successfully uninstalled setuptools-65.5.0", + "2023/09/12 12:11:53.789270259 Attempting uninstall: pip", + "2023/09/12 12:11:53.793350144 Found existing installation: pip 22.3.1", + "2023/09/12 12:11:54.663642768 Uninstalling pip-22.3.1:", + "2023/09/12 12:11:54.774633341 Successfully uninstalled pip-22.3.1", + "2023/09/12 12:12:06.254491799 Successfully installed pip-23.2.1 setuptools-68.2.1 wheel-0.41.2 ", + }, + Last: 37, + }, + nextOp: events.PublishRestorePythonEnvOp, + }, + { + task: taskDTO{ + Id: taskID, + Output: []string{ + "2023/09/12 12:12:07.965738756 Collecting anyio==3.6.2 (from -r python/requirements.txt (line 1))", + "2023/09/12 12:12:07.975267843 Using cached anyio-3.6.2-py3-none-any.whl (80 kB)", + "2023/09/12 12:12:08.083943365 Collecting argon2-cffi==21.3.0 (from -r python/requirements.txt (line 2))", + "2023/09/12 12:12:08.100883289 Using cached argon2_cffi-21.3.0-py3-none-any.whl (14 kB)", + }, + Last: 43, + }, + nextOp: events.PublishRestorePythonEnvOp, + }, + { + task: taskDTO{ + Id: taskID, + Output: []string{ + "2023/09/12 12:12:19.773456111 Installing collected packages: anyio, wcwidth, textwrap3, pure-eval, ptyprocess, pickleshare, mistune, ipython-genutils, fastjsonschema, executing, backcall, widgetsnbextension, websocket-client, webcolors, urllib3, uri-template, traitlets, tqdm, tornado, tinycss2, testpath, tenacity, soupsieve, sniffio, six, Send2Trash, rfc3986-validator, pyzmq, PyYAML, python-json-logger, pyrsistent, pyparsing, Pygments, pycparser, psutil, prompt-toolkit, prometheus-client, platformdirs, pexpect, parso, pandocfilters, packaging, nest-asyncio, MarkupSafe, jupyterlab-widgets, jupyterlab-pygments, jsonpointer, idna, fqdn, entrypoints, defusedxml, decorator, debugpy, click, charset-normalizer, certifi, attrs, ansiwrap, terminado, rfc3339-validator, requests, QtPy, python-dateutil, matplotlib-inline, jupyter_core, jsonschema, Jinja2, jedi, comm, cffi, bleach, beautifulsoup4, asttokens, anyio, stack-data, nbformat, jupyter_server_terminals, jupyter_client, arrow, argon2-cffi-bindings, nbclient, isoduration, ipython, argon2-cffi, papermill, nbconvert, ipykernel, qtconsole, jupyter-events, jupyter-console, ipywidgets, jupyter_server, notebook_shim, nbclassic, notebook, jupyter", + "2023/09/12 12:13:38.459203951 Successfully installed Jinja2-3.1.2 MarkupSafe-2.1.2 PyYAML-6.0 Pygments-2.15.1 QtPy-2.3.1 Send2Trash-1.8.2 ansiwrap-0.8.4 anyio-3.6.2 argon2-cffi-21.3.0 argon2-cffi-bindings-21.2.0 arrow-1.2.3 asttokens-2.2.1 attrs-23.1.0 backcall-0.2.0 beautifulsoup4-4.12.2 bleach-6.0.0 certifi-2023.7.22 cffi-1.15.1 charset-normalizer-3.2.0 click-8.1.7 comm-0.1.3 debugpy-1.6.7 decorator-5.1.1 defusedxml-0.7.1 entrypoints-0.4 executing-1.2.0 fastjsonschema-2.16.3 fqdn-1.5.1 idna-3.4 ipykernel-6.23.0 ipython-8.13.2 ipython-genutils-0.2.0 ipywidgets-8.0.6 isoduration-20.11.0 jedi-0.18.2 jsonpointer-2.3 jsonschema-4.17.3 jupyter-1.0.0 jupyter-console-6.6.3 jupyter-events-0.6.3 jupyter_client-8.2.0 jupyter_core-5.3.0 jupyter_server-2.5.0 jupyter_server_terminals-0.4.4 jupyterlab-pygments-0.2.2 jupyterlab-widgets-3.0.7 matplotlib-inline-0.1.6 mistune-2.0.5 nbclassic-1.0.0 nbclient-0.7.4 nbconvert-7.4.0 nbformat-5.8.0 nest-asyncio-1.5.6 notebook-6.5.4 notebook_shim-0.2.3 packaging-23.1 pandocfilters-1.5.0 papermill-2.4.0 parso-0.8.3 pexpect-4.8.0 pickleshare-0.7.5 platformdirs-3.5.1 prometheus-client-0.16.0 prompt-toolkit-3.0.38 psutil-5.9.5 ptyprocess-0.7.0 pure-eval-0.2.2 pycparser-2.21 pyparsing-3.0.9 pyrsistent-0.19.3 python-dateutil-2.8.2 python-json-logger-2.0.7 pyzmq-25.0.2 qtconsole-5.4.3 requests-2.31.0 rfc3339-validator-0.1.4 rfc3986-validator-0.1.1 six-1.16.0 sniffio-1.3.0 soupsieve-2.4.1 stack-data-0.6.2 tenacity-8.2.3 terminado-0.17.1 testpath-0.6.0 textwrap3-0.9.2 tinycss2-1.2.1 tornado-6.3.1 tqdm-4.66.1 traitlets-5.9.0 uri-template-1.2.0 urllib3-2.0.4 wcwidth-0.2.6 webcolors-1.13 webencodings-0.5.1 websocket-client-1.5.1 widgetsnbextension-4.0.7", + "2023/09/12 12:13:40.684793490 Packages in the environment: ansiwrap==0.8.4, anyio==3.6.2, argon2-cffi==21.3.0, argon2-cffi-bindings==21.2.0, arrow==1.2.3, asttokens==2.2.1, attrs==23.1.0, backcall==0.2.0, beautifulsoup4==4.12.2, bleach==6.0.0, certifi==2023.7.22, cffi==1.15.1, charset-normalizer==3.2.0, click==8.1.7, comm==0.1.3, debugpy==1.6.7, decorator==5.1.1, defusedxml==0.7.1, entrypoints==0.4, executing==1.2.0, fastjsonschema==2.16.3, fqdn==1.5.1, idna==3.4, ipykernel==6.23.0, ipython==8.13.2, ipython-genutils==0.2.0, ipywidgets==8.0.6, isoduration==20.11.0, jedi==0.18.2, Jinja2==3.1.2, jsonpointer==2.3, jsonschema==4.17.3, jupyter==1.0.0, jupyter-console==6.6.3, jupyter-events==0.6.3, jupyter_client==8.2.0, jupyter_core==5.3.0, jupyter_server==2.5.0, jupyter_server_terminals==0.4.4, jupyterlab-pygments==0.2.2, jupyterlab-widgets==3.0.7, MarkupSafe==2.1.2, matplotlib-inline==0.1.6, mistune==2.0.5, nbclassic==1.0.0, nbclient==0.7.4, nbconvert==7.4.0, nbformat==5.8.0, nest-asyncio==1.5.6, notebook==6.5.4, notebook_shim==0.2.3, packaging==23.1, pandocfilters==1.5.0, papermill==2.4.0, parso==0.8.3, pexpect==4.8.0, pickleshare==0.7.5, platformdirs==3.5.1, prometheus-client==0.16.0, prompt-toolkit==3.0.38, psutil==5.9.5, ptyprocess==0.7.0, pure-eval==0.2.2, pycparser==2.21, Pygments==2.15.1, pyparsing==3.0.9, pyrsistent==0.19.3, python-dateutil==2.8.2, python-json-logger==2.0.7, PyYAML==6.0, pyzmq==25.0.2, qtconsole==5.4.3, QtPy==2.3.1, requests==2.31.0, rfc3339-validator==0.1.4, rfc3986-validator==0.1.1, Send2Trash==1.8.2, six==1.16.0, sniffio==1.3.0, soupsieve==2.4.1, stack-data==0.6.2, tenacity==8.2.3, terminado==0.17.1, testpath==0.6.0, textwrap3==0.9.2, tinycss2==1.2.1, tornado==6.3.1, tqdm==4.66.1, traitlets==5.9.0, uri-template==1.2.0, urllib3==2.0.4, wcwidth==0.2.6, webcolors==1.13, webencodings==0.5.1, websocket-client==1.5.1, widgetsnbextension==4.0.7,", + "2023/09/12 12:13:40.684844104 Creating kernel spec: python3", + "2023/09/12 12:13:42.493712394 0.00s - Debugger warning: It seems that frozen modules are being used, which may", + "2023/09/12 12:13:42.493725446 0.00s - make the debugger miss breakpoints. Please pass -Xfrozen_modules=off", + "2023/09/12 12:13:42.493763373 0.00s - to python to disable frozen modules.", + "2023/09/12 12:13:42.493764977 0.00s - Note: Debugging will proceed. Set PYDEVD_DISABLE_FILE_VALIDATION=1 to disable this validation.", + "2023/09/12 12:13:42.787686354 Installed kernelspec python3 in /opt/rstudio-connect/mnt/app/python/env/share/jupyter/kernels/python3", + "2023/09/12 12:13:42.994165519 Creating lockfile: python/requirements.txt.lock", + "Completed Python build against Python version: '3.11.3' ", + }, + Last: 54, + }, + nextOp: events.PublishRestorePythonEnvOp, + }, + { + // This doesn't really make sense to come next, + // in fact it is from a different log, but here it is anyway. + task: taskDTO{ + Id: taskID, + Output: []string{ + "Launching Jupyter notebook...", + "Using environment Local", + "2023/09/12 12:13:44.541508100 [rsc-session] Content GUID: 04f24082-2396-484b-9839-f810891dce95", + "2023/09/12 12:13:44.541629598 [rsc-session] Content ID: 24257", + "2023/09/12 12:13:44.541635566 [rsc-session] Bundle ID: 41367", + "2023/09/12 12:13:44.541639071 [rsc-session] Variant ID: 5921", + "2023/09/12 12:13:44.541642353 [rsc-session] Job Key: l6FJXo5Ip5C4jyDf", + "2023/09/12 12:13:44.701717371 Running on host: dogfood02", + "2023/09/12 12:13:44.713132676 Linux distribution: Ubuntu 22.04.2 LTS (jammy)", + "2023/09/12 12:13:44.714771923 Running as user: uid=1031(rstudio-connect) gid=999(rstudio-connect) groups=999(rstudio-connect)", + "2023/09/12 12:13:44.714781982 Connect version: 2023.08.0-dev+835", + "2023/09/12 12:13:44.714807857 LANG: C.UTF-8", + "2023/09/12 12:13:44.714811167 Working directory: /opt/rstudio-connect/mnt/app", + "2023/09/12 12:13:44.714819234 Bootstrapping environment using Python 3.11.3 (main, Jun 4 2023, 22:34:28) [GCC 11.3.0] at /opt/python/3.11.3/bin/python3.11", + "2023/09/12 12:13:44.716751212 Running content with the Python virtual environment /opt/rstudio-connect/mnt/app/python/env (/opt/rstudio-connect/mnt/python-environments/pip/3.11.3/AbrY5VfQZ5r97HDk5puHtA) },", + }, + Finished: true, + Last: 69, + }, + nextOp: events.PublishRunContentOp, + }, + + { + task: taskDTO{ + Id: taskID, + Output: []string{ + "Bundle created with R version 4.3.0, Python version 3.11.3, and Quarto version 0.9.105 is compatible with environment Local with R version 4.3.1 from /opt/R/4.3.1/bin/R, Python version 3.11.3 from /opt/python/3.11.3/bin/python3.11, and Quarto version 1.3.450 from /opt/quarto/1.3.450/bin/quarto", + "Bundle requested R version 4.3.0; using /opt/R/4.3.1/bin/R which has version 4.3.1", + "Performing manifest.json to packrat transformation.", + "Rewriting .Rprofile to disable renv activation.", + "2023/09/12 17:02:42.966434822 [rsc-session] Content GUID: 067f9077-b831-4cff-bcd2-ee0797f27cb8", + "2023/09/12 17:02:42.966484486 [rsc-session] Content ID: 24275", + "2023/09/12 17:02:42.966491187 [rsc-session] Bundle ID: 41387", + "2023/09/12 17:02:42.966495586 [rsc-session] Job Key: MjLGWJMm4mkQCxRo", + "2023/09/12 17:02:44.214759306 Running on host: dogfood01", + "2023/09/12 17:02:44.240099958 Linux distribution: Ubuntu 22.04.2 LTS (jammy)", + "2023/09/12 17:02:44.243027005 Running as user: uid=1031(rstudio-connect) gid=999(rstudio-connect) groups=999(rstudio-connect)", + "2023/09/12 17:02:44.243096083 Connect version: 2023.08.0-dev+835", + "2023/09/12 17:02:44.243134910 LANG: C.UTF-8", + "2023/09/12 17:02:44.243454938 Working directory: /opt/rstudio-connect/mnt/app", + "2023/09/12 17:02:44.243650732 Using R 4.3.1", + "2023/09/12 17:02:44.243656003 R.home(): /opt/R/4.3.1/lib/R", + "2023/09/12 17:02:44.244676467 Using user agent string: 'RStudio R (4.3.1 x86_64-pc-linux-gnu x86_64 linux-gnu)'", + "2023/09/12 17:02:44.245211912 # Validating R library read / write permissions --------------------------------", + "2023/09/12 17:02:44.249695973 Using R library for packrat bootstrap: /opt/rstudio-connect/mnt/R/4.3.1", + "2023/09/12 17:02:44.250108685 # Validating managed packrat installation --------------------------------------", + "2023/09/12 17:02:44.250391017 Vendored packrat archive: /opt/rstudio-connect/ext/R/packrat_0.9.1-1_ac6bc33bce3869513cbe1ce14a697dfa807d9c41.tar.gz", + "2023/09/12 17:02:44.262948547 Vendored packrat SHA: ac6bc33bce3869513cbe1ce14a697dfa807d9c41", + "2023/09/12 17:02:44.276923165 Managed packrat SHA: ac6bc33bce3869513cbe1ce14a697dfa807d9c41", + "2023/09/12 17:02:44.278612673 Managed packrat version: 0.9.1.1", + "2023/09/12 17:02:44.279320329 Managed packrat is up-to-date.", + "2023/09/12 17:02:44.279664142 # Validating packrat cache read / write permissions ----------------------------", + "2023/09/12 17:02:44.643930307 Using packrat cache directory: /opt/rstudio-connect/mnt/packrat/4.3.1", + "2023/09/12 17:02:44.644104262 # Setting packrat options and preparing lockfile -------------------------------", + "2023/09/12 17:02:44.807459260 Audited package hashes with local packrat installation.", + "2023/09/12 17:02:44.809608665 # Resolving R package repositories ---------------------------------------------", + "2023/09/12 17:02:44.827081713 Received repositories from Connect's configuration:", + `2023/09/12 17:02:44.827696151 - CRAN = "https://packagemanager.posit.co/cran/__linux__/jammy/latest"`, + `2023/09/12 17:02:44.827703834 - RSPM = "https://packagemanager.posit.co/cran/__linux__/jammy/latest"`, + "2023/09/12 17:02:45.034481466 Received repositories from published content:", + `2023/09/12 17:02:45.036517369 - CRAN = "https://cran.rstudio.com"`, + "2023/09/12 17:02:45.041604661 Combining repositories from configuration and content.", + "2023/09/12 17:02:45.041811490 Packages will be installed using the following repositories:", + `2023/09/12 17:02:45.042593774 - CRAN = "https://packagemanager.posit.co/cran/__linux__/jammy/latest"`, + `2023/09/12 17:02:45.042601638 - RSPM = "https://packagemanager.posit.co/cran/__linux__/jammy/latest"`, + `2023/09/12 17:02:45.042624054 - CRAN.1 = "https://cran.rstudio.com"`, + "2023/09/12 17:02:45.061047966 # Installing required R packages with `packrat::restore()` ---------------------", + "2023/09/12 17:02:45.096309315 Warning in packrat::restore(overwrite.dirty = TRUE, prompt = FALSE, restart = FALSE) :", + "2023/09/12 17:02:45.096320185 The most recent snapshot was generated using R version 4.3.0", + "2023/09/12 17:02:45.141302848 Installing R6 (2.5.1) ...", + "2023/09/12 17:02:45.177720769 Using cached R6.", + "2023/09/12 17:02:45.179592403 OK (symlinked cache)", + "2023/09/12 17:02:45.179785920 Installing Rcpp (1.0.10) ...", + "2023/09/12 17:02:45.224715974 Using cached Rcpp.", + "2023/09/12 17:02:45.227149420 OK (symlinked cache)", + "Completed packrat build against R version: '4.3.1'", + }, + Last: 119, + }, + nextOp: events.PublishRestoreREnvOp, + }, + } + op := events.AgentOp + var err error + + for _, test := range tests { + op, err = handleTaskUpdate(&test.task, op, log) + if test.err != nil { + s.ErrorIs(err, test.err) + } else { + s.NoError(err) + } + s.Equal(test.nextOp, op) + } + log.AssertExpectations(s.T()) +} + +func (s *ConnectClientSuite) TestWaitForTaskErr() { + log := loggingtest.NewMockLogger() + + str := mock.AnythingOfType("string") + anything := mock.Anything + + log.On("Start", "Building Jupyter notebook...", logging.LogKeyOp, events.PublishRestorePythonEnvOp) + log.On("Info", str, str, anything) + + task := taskDTO{ + Id: types.TaskID("W3YpnrwUOQJxL5DS"), + Output: []string{ + "Building Jupyter notebook...", + "Bundle created with Python version 3.11.3 is compatible with environment Local with Python version 3.11.3 from /opt/python/3.11.3/bin/python3.11", + "Bundle requested Python version 3.11.3; using /opt/python/3.11.3/bin/python3.11 which has version 3.11.3", + "2023/09/12 13:34:48.308740036 Execution halted", + "Build error: exit status 1", + }, + Finished: true, + Error: "exit status 1", + Last: 5, + } + + op := events.Operation("") + op, err := handleTaskUpdate(&task, op, log) + s.Equal(&types.AgentError{ + Code: events.DeploymentFailedCode, + Err: errors.New("exit status 1"), + Data: types.ErrorData{}, + Op: events.PublishRestorePythonEnvOp, + }, err) + s.Equal(events.PublishRestorePythonEnvOp, op) + log.AssertExpectations(s.T()) +} diff --git a/internal/api_client/clients/http_client.go b/internal/api_client/clients/http_client.go index 1c7f562ec..1d346c3da 100644 --- a/internal/api_client/clients/http_client.go +++ b/internal/api_client/clients/http_client.go @@ -183,7 +183,7 @@ func loadCACertificates(path string, log logging.Logger) (*x509.CertPool, error) log.Info("Loading CA certificate", "path", path) certificate, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("Error reading certificate file: %s", err) + return nil, fmt.Errorf("Error reading certificate file: %w", err) } certPool := x509.NewCertPool() ok := certPool.AppendCertsFromPEM(certificate) diff --git a/internal/bundles/bundle.go b/internal/bundles/bundle.go index d10e7e38f..d60de5450 100644 --- a/internal/bundles/bundle.go +++ b/internal/bundles/bundle.go @@ -227,7 +227,7 @@ func (b *bundle) walkFunc(path util.Path, info fs.FileInfo, err error) error { if err != nil { return err } - pathLogger := b.log.With( + pathLogger := b.log.WithArgs( "path", path, "size", info.Size(), ) diff --git a/internal/bundles/gitignore/gitignore.go b/internal/bundles/gitignore/gitignore.go index 3db101f0b..f2e2e98db 100644 --- a/internal/bundles/gitignore/gitignore.go +++ b/internal/bundles/gitignore/gitignore.go @@ -333,8 +333,14 @@ func (ign *GitIgnoreList) match(path string, info os.FileInfo) *Match { // Match returns whether any of the globs in the ignore list match the // specified path. Uses the same matching rules as .gitignore files. -func (ign *GitIgnoreList) Match(path string) *Match { - return ign.match(path, nil) +func (ign *GitIgnoreList) Match(path string) (*Match, error) { + stat, err := ign.fs.Stat(path) + if err != nil { + if !os.IsNotExist(err) { + return nil, err + } + } + return ign.match(path, stat), nil } // Walk walks the file tree with the specified root and calls fn on each file diff --git a/internal/bundles/gitignore/gitignore_test.go b/internal/bundles/gitignore/gitignore_test.go index d0b247d15..9463a0f3a 100644 --- a/internal/bundles/gitignore/gitignore_test.go +++ b/internal/bundles/gitignore/gitignore_test.go @@ -57,11 +57,13 @@ func (s *GitIgnoreSuite) TestMatch() { s.NoError(err) // Match returns nil if no match - m := ign.Match("app.py") + m, err := ign.Match("app.py") + s.NoError(err) s.Nil(m) // File matches include file info - m = ign.Match(".Rhistory") + m, err = ign.Match(".Rhistory") + s.NoError(err) s.NotNil(m) s.Equal(MatchSourceFile, m.Source) s.Equal(".Rhistory", m.Pattern) @@ -69,10 +71,25 @@ func (s *GitIgnoreSuite) TestMatch() { s.Equal(1, m.Line) // Non-file matches don't include file info - m = ign.Match("app.py.bak") + m, err = ign.Match("app.py.bak") + s.NoError(err) s.NotNil(m) s.Equal(MatchSourceUser, m.Source) s.Equal("*.bak", m.Pattern) s.Equal("", m.FilePath) s.Equal(0, m.Line) + + ignoredir := s.cwd.Join("ignoredir") + err = ignoredir.MkdirAll(0700) + s.NoError(err) + err = ign.AppendGlobs([]string{"ignoredir/"}, MatchSourceUser) + s.NoError(err) + + m, err = ign.Match(ignoredir.Path()) + s.NoError(err) + s.NotNil(m) + s.Equal(MatchSourceUser, m.Source) + s.Equal("ignoredir/", m.Pattern) + s.Equal("", m.FilePath) + s.Equal(0, m.Line) } diff --git a/internal/bundles/gitignore/interface.go b/internal/bundles/gitignore/interface.go index 0ab74568d..0a9721be8 100644 --- a/internal/bundles/gitignore/interface.go +++ b/internal/bundles/gitignore/interface.go @@ -12,7 +12,7 @@ type IgnoreList interface { Append(path util.Path) error AppendGlobs(patterns []string, source MatchSource) error AppendGit() error - Match(path string) *Match + Match(path string) (*Match, error) Walk(root util.Path, fn util.WalkFunc) error } @@ -38,9 +38,9 @@ func (m *MockGitIgnoreList) AppendGit() error { return args.Error(0) } -func (m *MockGitIgnoreList) Match(path string) bool { +func (m *MockGitIgnoreList) Match(path string) (*Match, error) { args := m.Called(path) - return args.Bool(0) + return args.Get(0).(*Match), args.Error(1) } func (m *MockGitIgnoreList) Walk(root util.Path, fn util.WalkFunc) error { @@ -48,6 +48,8 @@ func (m *MockGitIgnoreList) Walk(root util.Path, fn util.WalkFunc) error { return args.Error(0) } +var _ IgnoreList = &MockGitIgnoreList{} + // Maintain a reference to the original gitignore so it // and its license remain in our vendor directory. type _ gitignore.IgnoreList diff --git a/internal/events/events.go b/internal/events/events.go index 7ea0540f9..dab1bcecd 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -34,6 +34,7 @@ const ( PublishRestoreREnvOp Operation = "publish/restoreREnv" PublishRunContentOp Operation = "publish/runContent" PublishSetVanityUrlOp Operation = "publish/setVanityURL" + PublishOp Operation = "publish" ) func EventTypeOf(op Operation, phase Phase, errCode ErrorCode) EventType { diff --git a/internal/events/sse_logger.go b/internal/events/sse_logger.go index d33e0c792..9df13c24b 100644 --- a/internal/events/sse_logger.go +++ b/internal/events/sse_logger.go @@ -24,10 +24,8 @@ func NewLogger(debug bool) logging.Logger { return logging.FromStdLogger(slog.New(stderrHandler)) } -func NewLoggerWithSSE(debug bool) logging.Logger { +func NewLoggerWithSSE(debug bool, eventServer *sse.Server) logging.Logger { level := logLevel(debug) - eventServer := sse.New() - eventServer.CreateStream("messages") stderrHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}) sseHandler := NewSSEHandler(eventServer, &SSEHandlerOptions{Level: level}) multiHandler := logging.NewMultiHandler(stderrHandler, sseHandler) diff --git a/internal/events/sse_logger_test.go b/internal/events/sse_logger_test.go index 204ffb265..9b9356241 100644 --- a/internal/events/sse_logger_test.go +++ b/internal/events/sse_logger_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "testing" + "github.com/r3labs/sse/v2" "github.com/rstudio/connect-client/internal/logging" "github.com/rstudio/connect-client/internal/util/utiltest" "github.com/stretchr/testify/suite" @@ -35,6 +36,7 @@ func (s *LoggerSuite) TestNewLoggerDebug() { } func (s *LoggerSuite) TestNewLoggerWithSSE() { - log := NewLoggerWithSSE(false) + sseServer := sse.New() + log := NewLoggerWithSSE(false, sseServer) s.IsType(log.Handler(), &logging.MultiHandler{}) } diff --git a/internal/logging/logger.go b/internal/logging/logger.go index ceee531ea..c45286c2b 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -28,37 +28,47 @@ const ( LogKeyErrCode = "error_code" ) -type Logger struct { +type Logger interface { + BaseLogger + Start(msg string, args ...any) + Success(msg string, args ...any) + Status(msg string, args ...any) + Progress(msg string, done float32, total float32, args ...any) + Failure(err error) + WithArgs(args ...any) Logger +} + +type logger struct { BaseLogger } func New() Logger { - return Logger{ + return logger{ slog.Default(), } } func FromStdLogger(log *slog.Logger) Logger { - return Logger{log} + return logger{log} } -func (l Logger) Start(msg string, args ...any) { +func (l logger) Start(msg string, args ...any) { l.Info(msg, append([]any{LogKeyPhase, StartPhase}, args...)...) } -func (l Logger) Success(msg string, args ...any) { +func (l logger) Success(msg string, args ...any) { l.Info(msg, append([]any{LogKeyPhase, SuccessPhase}, args...)...) } -func (l Logger) Status(msg string, args ...any) { +func (l logger) Status(msg string, args ...any) { l.Info(msg, append([]any{LogKeyPhase, ProgressPhase}, args...)...) } -func (l Logger) Progress(msg string, done float32, total float32, args ...any) { +func (l logger) Progress(msg string, done float32, total float32, args ...any) { l.Info(msg, append([]any{LogKeyPhase, ProgressPhase, "done", done, "total", total}, args...)...) } -func (l Logger) Failure(err error) { +func (l logger) Failure(err error) { if agentError, ok := err.(types.EventableError); ok { args := []any{ LogKeyOp, agentError.GetOperation(), @@ -73,11 +83,11 @@ func (l Logger) Failure(err error) { // We shouldn't get here, because callers who use Failure // (the Publish routine) will wrap all errors in AgentErrors. // But just in case, log it anyway. - l.Debug("Received a non-eventable error in Logger.Failure; see the following error entry") + l.Debug("Received a non-eventable error in LoggerImpl.Failure; see the following error entry") l.Error(err.Error(), LogKeyPhase, FailurePhase) } } -func (l Logger) With(args ...any) Logger { - return Logger{l.BaseLogger.With(args...)} +func (l logger) WithArgs(args ...any) Logger { + return logger{l.BaseLogger.With(args...)} } diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go index 3d3ebb9e2..de43c5ce5 100644 --- a/internal/logging/logger_test.go +++ b/internal/logging/logger_test.go @@ -7,7 +7,6 @@ import ( "log/slog" "testing" - "github.com/rstudio/connect-client/internal/logging/loggingtest" "github.com/rstudio/connect-client/internal/types" "github.com/rstudio/connect-client/internal/util/utiltest" "github.com/stretchr/testify/mock" @@ -23,46 +22,46 @@ func TestLoggingSuite(t *testing.T) { } func (s *LoggingSuite) TestDefaultLogger() { - log := New() + log := New().(logger) s.NotNil(log.BaseLogger) } func (s *LoggingSuite) TestFromStdLogger() { stdLogger := slog.Default() - log := FromStdLogger(stdLogger) + log := FromStdLogger(stdLogger).(logger) s.NotNil(log.BaseLogger) s.Equal(stdLogger, log.BaseLogger) } func (s *LoggingSuite) TestStart() { - baseLogger := loggingtest.NewMockLogger() + baseLogger := NewMockBaseLogger() baseLogger.On("Info", "message", LogKeyPhase, StartPhase, "arg", "value") - log := Logger{baseLogger} + log := logger{baseLogger} log.Start("message", "arg", "value") - s.Assert() + baseLogger.AssertExpectations(s.T()) } func (s *LoggingSuite) TestSuccess() { - baseLogger := loggingtest.NewMockLogger() + baseLogger := NewMockBaseLogger() baseLogger.On("Info", "message", LogKeyPhase, SuccessPhase, "arg", "value") - log := Logger{baseLogger} + log := logger{baseLogger} log.Success("message", "arg", "value") - s.Assert() + baseLogger.AssertExpectations(s.T()) } func (s *LoggingSuite) TestStatus() { - baseLogger := loggingtest.NewMockLogger() + baseLogger := NewMockBaseLogger() baseLogger.On("Info", "message", LogKeyPhase, ProgressPhase, "arg", "value") - log := Logger{baseLogger} + log := logger{baseLogger} log.Status("message", "arg", "value") - s.Assert() + baseLogger.AssertExpectations(s.T()) } func (s *LoggingSuite) TestProgress() { - baseLogger := loggingtest.NewMockLogger() + baseLogger := NewMockBaseLogger() baseLogger.On( "Info", "message", LogKeyPhase, ProgressPhase, @@ -70,24 +69,24 @@ func (s *LoggingSuite) TestProgress() { "total", float32(100), "arg", "value") - log := Logger{baseLogger} + log := logger{baseLogger} log.Progress("message", 20, 100, "arg", "value") - s.Assert() + baseLogger.AssertExpectations(s.T()) } func (s *LoggingSuite) TestFailureGoError() { - baseLogger := loggingtest.NewMockLogger() + baseLogger := NewMockBaseLogger() baseLogger.On("Error", "test error", LogKeyPhase, FailurePhase) baseLogger.On("Debug", mock.AnythingOfType("string")) - log := Logger{baseLogger} + log := logger{baseLogger} err := errors.New("test error") log.Failure(err) - s.Assert() + baseLogger.AssertExpectations(s.T()) } func (s *LoggingSuite) TestFailureAgentError() { - baseLogger := loggingtest.NewMockLogger() + baseLogger := NewMockBaseLogger() op := types.Operation("testOp") baseLogger.On( @@ -97,7 +96,7 @@ func (s *LoggingSuite) TestFailureAgentError() { LogKeyErrCode, types.UnknownErrorCode, "Metadata", "some metadata") - log := Logger{baseLogger} + log := logger{baseLogger} baseErr := errors.New("test error") errData := struct { Metadata string @@ -105,14 +104,14 @@ func (s *LoggingSuite) TestFailureAgentError() { err := types.NewAgentError(types.UnknownErrorCode, baseErr, &errData) err.SetOperation(op) log.Failure(err) - s.Assert() + baseLogger.AssertExpectations(s.T()) } func (s *LoggingSuite) TestWith() { - baseLogger := loggingtest.NewMockLogger() + baseLogger := NewMockBaseLogger() expectedLogger := slog.Default() baseLogger.On("With", "arg", "value", "arg2", "value2").Return(expectedLogger) - log := Logger{baseLogger} - actualLogger := log.With("arg", "value", "arg2", "value2") - s.Equal(Logger{expectedLogger}, actualLogger) + log := logger{baseLogger} + actualLogger := log.WithArgs("arg", "value", "arg2", "value2") + s.Equal(logger{expectedLogger}, actualLogger) } diff --git a/internal/logging/loggingtest/mock_logger.go b/internal/logging/loggingtest/mock_logger.go index 01fd82d73..85828400e 100644 --- a/internal/logging/loggingtest/mock_logger.go +++ b/internal/logging/loggingtest/mock_logger.go @@ -3,61 +3,42 @@ package loggingtest // Copyright (C) 2023 by Posit Software, PBC. import ( - "context" - "log/slog" - - "github.com/stretchr/testify/mock" + "github.com/rstudio/connect-client/internal/logging" ) type MockLogger struct { - mock.Mock + logging.MockBaseLogger } func NewMockLogger() *MockLogger { return &MockLogger{} } -func (m *MockLogger) Error(msg string, args ...any) { - mockArgs := append([]any{msg}, args...) - m.Called(mockArgs...) -} - -func (m *MockLogger) Warn(msg string, args ...any) { +func (m *MockLogger) Start(msg string, args ...any) { mockArgs := append([]any{msg}, args...) m.Called(mockArgs...) } -func (m *MockLogger) Info(msg string, args ...any) { +func (m *MockLogger) Success(msg string, args ...any) { mockArgs := append([]any{msg}, args...) m.Called(mockArgs...) } -func (m *MockLogger) Debug(msg string, args ...any) { +func (m *MockLogger) Status(msg string, args ...any) { mockArgs := append([]any{msg}, args...) m.Called(mockArgs...) } -func (m *MockLogger) Log(ctx context.Context, level slog.Level, msg string, args ...any) { - mockArgs := append([]any{ctx, level, msg}, args...) +func (m *MockLogger) Progress(msg string, done float32, total float32, args ...any) { + mockArgs := append([]any{msg, done, total}, args...) m.Called(mockArgs...) } -func (m *MockLogger) Handler() slog.Handler { - mockArgs := m.Called() - return mockArgs.Get(0).(slog.Handler) +func (m *MockLogger) Failure(err error) { + m.Called(err) } -func (m *MockLogger) Enabled(ctx context.Context, level slog.Level) bool { - args := m.Called(ctx, level) - return args.Bool(0) -} - -func (m *MockLogger) With(args ...any) *slog.Logger { +func (m *MockLogger) WithArgs(args ...any) logging.Logger { mockArgs := m.Called(args...) - return mockArgs.Get(0).(*slog.Logger) -} - -func (m *MockLogger) WithGroup(name string) *slog.Logger { - mockArgs := m.Called(name) - return mockArgs.Get(0).(*slog.Logger) + return mockArgs.Get(0).(logging.Logger) } diff --git a/internal/logging/mock_base_logger.go b/internal/logging/mock_base_logger.go new file mode 100644 index 000000000..569601d5c --- /dev/null +++ b/internal/logging/mock_base_logger.go @@ -0,0 +1,63 @@ +package logging + +// Copyright (C) 2023 by Posit Software, PBC. + +import ( + "context" + "log/slog" + + "github.com/stretchr/testify/mock" +) + +type MockBaseLogger struct { + mock.Mock +} + +func NewMockBaseLogger() *MockBaseLogger { + return &MockBaseLogger{} +} + +func (m *MockBaseLogger) Error(msg string, args ...any) { + mockArgs := append([]any{msg}, args...) + m.Called(mockArgs...) +} + +func (m *MockBaseLogger) Warn(msg string, args ...any) { + mockArgs := append([]any{msg}, args...) + m.Called(mockArgs...) +} + +func (m *MockBaseLogger) Info(msg string, args ...any) { + mockArgs := append([]any{msg}, args...) + m.Called(mockArgs...) +} + +func (m *MockBaseLogger) Debug(msg string, args ...any) { + mockArgs := append([]any{msg}, args...) + m.Called(mockArgs...) +} + +func (m *MockBaseLogger) Log(ctx context.Context, level slog.Level, msg string, args ...any) { + mockArgs := append([]any{ctx, level, msg}, args...) + m.Called(mockArgs...) +} + +func (m *MockBaseLogger) Handler() slog.Handler { + mockArgs := m.Called() + return mockArgs.Get(0).(slog.Handler) +} + +func (m *MockBaseLogger) Enabled(ctx context.Context, level slog.Level) bool { + args := m.Called(ctx, level) + return args.Bool(0) +} + +func (m *MockBaseLogger) With(args ...any) *slog.Logger { + mockArgs := m.Called(args...) + return mockArgs.Get(0).(*slog.Logger) +} + +func (m *MockBaseLogger) WithGroup(name string) *slog.Logger { + mockArgs := m.Called(name) + return mockArgs.Get(0).(*slog.Logger) +} diff --git a/internal/logging/loggingtest/mock_handler.go b/internal/logging/mock_handler.go similarity index 97% rename from internal/logging/loggingtest/mock_handler.go rename to internal/logging/mock_handler.go index d65d26082..bc16ca9cf 100644 --- a/internal/logging/loggingtest/mock_handler.go +++ b/internal/logging/mock_handler.go @@ -1,4 +1,4 @@ -package loggingtest +package logging // Copyright (C) 2023 by Posit Software, PBC. diff --git a/internal/logging/multi_handler_test.go b/internal/logging/multi_handler_test.go index d28c8f8e3..0941c681a 100644 --- a/internal/logging/multi_handler_test.go +++ b/internal/logging/multi_handler_test.go @@ -9,7 +9,6 @@ import ( "os" "testing" - "github.com/rstudio/connect-client/internal/logging/loggingtest" "github.com/rstudio/connect-client/internal/util/utiltest" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" @@ -24,8 +23,8 @@ func TestMultiHanlderSuite(t *testing.T) { } func (s *MultiHandlerSuite) TestNewMultiHandler() { - h1 := loggingtest.NewMockHandler() - h2 := loggingtest.NewMockHandler() + h1 := NewMockHandler() + h2 := NewMockHandler() multiHandler := NewMultiHandler(h1, h2) s.Equal([]slog.Handler{h1, h2}, multiHandler.handlers) @@ -44,8 +43,8 @@ func (s *MultiHandlerSuite) TestEnabled() { } func (s *MultiHandlerSuite) TestHandle() { - h1 := loggingtest.NewMockHandler() - h2 := loggingtest.NewMockHandler() + h1 := NewMockHandler() + h2 := NewMockHandler() h1.On("Enabled", mock.Anything, slog.LevelInfo).Return(true) h2.On("Enabled", mock.Anything, slog.LevelInfo).Return(false) @@ -57,11 +56,12 @@ func (s *MultiHandlerSuite) TestHandle() { Message: "message", } multiHandler.Handle(context.Background(), record) - s.Assert() + h1.AssertExpectations(s.T()) + h2.AssertExpectations(s.T()) } func (s *MultiHandlerSuite) TestHandleError() { - baseHandler := loggingtest.NewMockHandler() + baseHandler := NewMockHandler() testError := errors.New("test error from Handle") baseHandler.On("Enabled", mock.Anything, slog.LevelInfo).Return(true) baseHandler.On("Handle", mock.Anything, mock.Anything).Return(testError) @@ -73,14 +73,14 @@ func (s *MultiHandlerSuite) TestHandleError() { } err := multiHandler.Handle(context.Background(), record) s.ErrorIs(err, testError) - s.Assert() + baseHandler.AssertExpectations(s.T()) } func (s *MultiHandlerSuite) TestWithAttrs() { - h1 := loggingtest.NewMockHandler() - h2 := loggingtest.NewMockHandler() - h1WithAttrs := loggingtest.NewMockHandler() - h2WithAttrs := loggingtest.NewMockHandler() + h1 := NewMockHandler() + h2 := NewMockHandler() + h1WithAttrs := NewMockHandler() + h2WithAttrs := NewMockHandler() attr := slog.Attr{ Key: "att", @@ -93,27 +93,29 @@ func (s *MultiHandlerSuite) TestWithAttrs() { multiHandler := NewMultiHandler(h1, h2) returnedHandler := multiHandler.WithAttrs(attrs) s.Equal(NewMultiHandler(h1WithAttrs, h2WithAttrs), returnedHandler) - s.Assert() + h1.AssertExpectations(s.T()) + h2.AssertExpectations(s.T()) } func (s *MultiHandlerSuite) TestWithGroup() { - h1 := loggingtest.NewMockHandler() - h2 := loggingtest.NewMockHandler() - h1WithGroup := loggingtest.NewMockHandler() - h2WithGroup := loggingtest.NewMockHandler() + h1 := NewMockHandler() + h2 := NewMockHandler() + h1WithGroup := NewMockHandler() + h2WithGroup := NewMockHandler() h1.On("WithGroup", "group").Return(h1WithGroup) h2.On("WithGroup", "group").Return(h2WithGroup) multiHandler := NewMultiHandler(h1, h2) returnedHandler := multiHandler.WithGroup("group") s.Equal(NewMultiHandler(h1WithGroup, h2WithGroup), returnedHandler) - s.Assert() + h1.AssertExpectations(s.T()) + h2.AssertExpectations(s.T()) } func (s *MultiHandlerSuite) TestWithGroupEmptyName() { - baseHandler := loggingtest.NewMockHandler() + baseHandler := NewMockHandler() multiHandler := NewMultiHandler(baseHandler) returnedHandler := multiHandler.WithGroup("") s.Equal(multiHandler, returnedHandler) - s.Assert() + baseHandler.AssertExpectations(s.T()) } diff --git a/internal/publish/publish.go b/internal/publish/publish.go index af29fcf0d..bf3efdded 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -21,13 +21,23 @@ import ( "github.com/rstudio/connect-client/internal/util" ) -func CreateBundleFromDirectory(cmd *cli_types.PublishArgs, dest util.Path, log logging.Logger) error { +type Publisher struct { + args *cli_types.PublishArgs +} + +func New(args *cli_types.PublishArgs) *Publisher { + return &Publisher{ + args: args, + } +} + +func (p *Publisher) CreateBundleFromDirectory(dest util.Path, log logging.Logger) error { bundleFile, err := dest.Create() if err != nil { return err } defer bundleFile.Close() - bundler, err := bundles.NewBundler(cmd.State.SourceDir, &cmd.State.Manifest, cmd.Exclude, nil, log) + bundler, err := bundles.NewBundler(p.args.State.SourceDir, &p.args.State.Manifest, p.args.Exclude, nil, log) if err != nil { return err } @@ -35,8 +45,8 @@ func CreateBundleFromDirectory(cmd *cli_types.PublishArgs, dest util.Path, log l return err } -func WriteManifestFromDirectory(cmd *cli_types.PublishArgs, log logging.Logger) error { - bundler, err := bundles.NewBundler(cmd.State.SourceDir, &cmd.State.Manifest, cmd.Exclude, nil, log) +func (p *Publisher) WriteManifestFromDirectory(log logging.Logger) error { + bundler, err := bundles.NewBundler(p.args.State.SourceDir, &p.args.State.Manifest, p.args.Exclude, nil, log) if err != nil { return err } @@ -44,7 +54,7 @@ func WriteManifestFromDirectory(cmd *cli_types.PublishArgs, log logging.Logger) if err != nil { return err } - manifestPath := cmd.State.SourceDir.Join(bundles.ManifestFilename) + manifestPath := p.args.State.SourceDir.Join(bundles.ManifestFilename) log.Info("Writing manifest", "path", manifestPath) manifestJSON, err := manifest.ToJSON() if err != nil { @@ -62,17 +72,18 @@ type appInfo struct { DirectURL string `json:"direct-url"` } -func logAppInfo(accountURL string, contentID types.ContentID, log logging.Logger) error { +func (p *Publisher) logAppInfo(accountURL string, contentID types.ContentID, log logging.Logger) error { appInfo := appInfo{ DashboardURL: fmt.Sprintf("%s/connect/#/apps/%s", accountURL, contentID), DirectURL: fmt.Sprintf("%s/content/%s", accountURL, contentID), } - log.With( + log.Success("Deployment successful", + logging.LogKeyOp, events.PublishOp, "dashboardURL", appInfo.DashboardURL, "directURL", appInfo.DirectURL, "serverURL", accountURL, "contentID", contentID, - ).Info("Deployment successful") + ) jsonInfo, err := json.Marshal(appInfo) if err != nil { return err @@ -81,25 +92,29 @@ func logAppInfo(accountURL string, contentID types.ContentID, log logging.Logger return err } -func PublishManifestFiles(cmd *cli_types.PublishArgs, lister accounts.AccountList, log logging.Logger) error { - bundler, err := bundles.NewBundlerForManifest(cmd.State.SourceDir, &cmd.State.Manifest, log) +func (p *Publisher) PublishManifestFiles(lister accounts.AccountList, log logging.Logger) error { + bundler, err := bundles.NewBundlerForManifest(p.args.State.SourceDir, &p.args.State.Manifest, log) if err != nil { return err } - return publish(cmd, bundler, lister, log) + return p.publish(bundler, lister, log) } -func PublishDirectory(cmd *cli_types.PublishArgs, lister accounts.AccountList, log logging.Logger) error { - log.Info("Publishing from directory", "path", cmd.State.SourceDir) - bundler, err := bundles.NewBundler(cmd.State.SourceDir, &cmd.State.Manifest, cmd.Exclude, nil, log) +func (p *Publisher) PublishDirectory(lister accounts.AccountList, log logging.Logger) error { + log.Info("Publishing from directory", "path", p.args.State.SourceDir) + bundler, err := bundles.NewBundler(p.args.State.SourceDir, &p.args.State.Manifest, p.args.Exclude, nil, log) if err != nil { return err } - return publish(cmd, bundler, lister, log) + return p.publish(bundler, lister, log) } -func publish(cmd *cli_types.PublishArgs, bundler bundles.Bundler, lister accounts.AccountList, log logging.Logger) error { - account, err := lister.GetAccountByName(cmd.State.Target.AccountName) +func (p *Publisher) publish( + bundler bundles.Bundler, + lister accounts.AccountList, + log logging.Logger) error { + + account, err := lister.GetAccountByName(p.args.State.Target.AccountName) if err != nil { return err } @@ -109,7 +124,7 @@ func publish(cmd *cli_types.PublishArgs, bundler bundles.Bundler, lister account if err != nil { return err } - err = publishWithClient(cmd, bundler, account, client, log) + err = p.publishWithClient(bundler, account, client, log) if err != nil { log.Failure(err) } @@ -120,8 +135,14 @@ type DeploymentNotFoundDetails struct { ContentID types.ContentID } -func withLog[T any](op events.Operation, msg string, log logging.Logger, fn func() (T, error)) (value T, err error) { - log = log.With(logging.LogKeyOp, op) +func withLog[T any]( + op events.Operation, + msg string, + label string, + log logging.Logger, + fn func() (T, error)) (value T, err error) { + + log = log.WithArgs(logging.LogKeyOp, op) log.Start(msg) value, err = fn() if err != nil { @@ -130,15 +151,20 @@ func withLog[T any](op events.Operation, msg string, log logging.Logger, fn func err = types.ErrToAgentError(op, err) return } - log.Success("Done " + msg) + log.Success("Done", label, value) return value, nil } -func publishWithClient(cmd *cli_types.PublishArgs, bundler bundles.Bundler, account *accounts.Account, client clients.APIClient, log logging.Logger) error { - log = log.With( +func (p *Publisher) publishWithClient( + bundler bundles.Bundler, + account *accounts.Account, + client clients.APIClient, + log logging.Logger) error { + + log.Start("Starting deployment to server", + logging.LogKeyOp, events.PublishOp, "server", account.URL, - logging.LogKeyOp, events.PublishCreateBundleOp) - log.Start("Creating bundle") + ) bundleFile, err := os.CreateTemp("", "bundle-*.tar.gz") if err != nil { return types.ErrToAgentError(events.PublishCreateBundleOp, err) @@ -146,7 +172,10 @@ func publishWithClient(cmd *cli_types.PublishArgs, bundler bundles.Bundler, acco defer os.Remove(bundleFile.Name()) defer bundleFile.Close() - _, err = bundler.CreateBundle(bundleFile) + _, err = withLog(events.PublishCreateBundleOp, "Creating bundle", "filename", log, func() (any, error) { + _, err := bundler.CreateBundle(bundleFile) + return bundleFile.Name(), err + }) if err != nil { return types.ErrToAgentError(events.PublishCreateBundleOp, err) } @@ -154,14 +183,12 @@ func publishWithClient(cmd *cli_types.PublishArgs, bundler bundles.Bundler, acco if err != nil { return types.ErrToAgentError(events.PublishCreateBundleOp, err) } - log.Success("Done creating bundle") var contentID types.ContentID - if cmd.State.Target.ContentId != "" && !cmd.New { - contentID = cmd.State.Target.ContentId - log = log.With("content_id", contentID) - _, err := withLog(events.PublishCreateDeploymentOp, "Updating deployment", log, func() (any, error) { - return nil, client.UpdateDeployment(contentID, cmd.State.Connect.Content) + if p.args.State.Target.ContentId != "" && !p.args.New { + contentID = p.args.State.Target.ContentId + _, err := withLog(events.PublishCreateDeploymentOp, "Updating deployment", "content_id", log, func() (any, error) { + return contentID, client.UpdateDeployment(contentID, p.args.State.Connect.Content) }) if err != nil { httpErr, ok := err.(*clients.HTTPError) @@ -174,24 +201,23 @@ func publishWithClient(cmd *cli_types.PublishArgs, bundler bundles.Bundler, acco return err } } else { - contentID, err = withLog(events.PublishCreateDeploymentOp, "Creating deployment", log, func() (types.ContentID, error) { - return client.CreateDeployment(cmd.State.Connect.Content) + contentID, err = withLog(events.PublishCreateDeploymentOp, "Creating deployment", "content_id", log, func() (types.ContentID, error) { + return client.CreateDeployment(p.args.State.Connect.Content) }) if err != nil { return err } - log = log.With("content_id", contentID) + log.Info("content_id", contentID) } - bundleID, err := withLog(events.PublishUploadBundleOp, "Uploading deployment bundle", log, func() (types.BundleID, error) { + bundleID, err := withLog(events.PublishUploadBundleOp, "Uploading deployment bundle", "bundle_id", log, func() (types.BundleID, error) { return client.UploadBundle(contentID, bundleFile) }) if err != nil { return err } - log = log.With("bundle_id", bundleID) - cmd.State.Target = state.TargetID{ + p.args.State.Target = state.TargetID{ ServerType: account.ServerType, AccountName: account.Name, ServerURL: account.URL, @@ -202,19 +228,18 @@ func publishWithClient(cmd *cli_types.PublishArgs, bundler bundles.Bundler, acco DeployedAt: types.NewOptional(time.Now()), } - taskID, err := withLog(events.PublishDeployBundleOp, "Initiating bundle deployment", log, func() (types.TaskID, error) { + taskID, err := withLog(events.PublishDeployBundleOp, "Initiating bundle deployment", "task_id", log, func() (types.TaskID, error) { return client.DeployBundle(contentID, bundleID) }) if err != nil { return err } - log = log.With("task_id", taskID) - taskLogger := log.With("source", "serverLog") + taskLogger := log.WithArgs("source", "serverp.log") err = client.WaitForTask(taskID, taskLogger) if err != nil { return err } - log = log.With(logging.LogKeyOp, events.AgentOp) - return logAppInfo(account.URL, contentID, log) + log = log.WithArgs(logging.LogKeyOp, events.AgentOp) + return p.logAppInfo(account.URL, contentID, log) } diff --git a/internal/publish/publish_test.go b/internal/publish/publish_test.go index 0bfed1024..8752bcc73 100644 --- a/internal/publish/publish_test.go +++ b/internal/publish/publish_test.go @@ -54,7 +54,8 @@ func (s *PublishSuite) TestCreateBundle() { State: state.NewDeployment(), } cmd.State.SourceDir = s.cwd - err := CreateBundleFromDirectory(cmd, dest, s.log) + publisher := New(cmd) + err := publisher.CreateBundleFromDirectory(dest, s.log) s.NoError(err) s.True(dest.Exists()) } @@ -70,7 +71,8 @@ func (s *PublishSuite) TestCreateBundleFailCreate() { State: state.NewDeployment(), } cmd.State.SourceDir = s.cwd - err := CreateBundleFromDirectory(cmd, dest, s.log) + publisher := New(cmd) + err := publisher.CreateBundleFromDirectory(dest, s.log) s.ErrorIs(err, testError) } @@ -83,7 +85,8 @@ func (s *PublishSuite) TestWriteManifest() { } cmd.State.SourceDir = s.cwd - err := WriteManifestFromDirectory(cmd, s.log) + publisher := New(cmd) + err := publisher.WriteManifestFromDirectory(s.log) s.NoError(err) s.True(manifestPath.Exists()) @@ -143,7 +146,9 @@ func (s *PublishSuite) publishWithClient(createErr, uploadErr, deployErr, waitEr client.On("UploadBundle", myContentID, mock.Anything).Return(myBundleID, uploadErr) client.On("DeployBundle", myContentID, myBundleID).Return(myTaskID, deployErr) client.On("WaitForTask", myTaskID, mock.Anything).Return(waitErr) - err = publishWithClient(cmd, bundler, account, client, s.log) + + publisher := New(cmd) + err = publisher.publishWithClient(bundler, account, client, s.log) if expectedErr == nil { s.NoError(err) } else { diff --git a/internal/services/api/files/files.go b/internal/services/api/files/files.go index eee18ad19..4770d2bc9 100644 --- a/internal/services/api/files/files.go +++ b/internal/services/api/files/files.go @@ -86,7 +86,11 @@ func (f *File) insert(root util.Path, path util.Path, ignore gitignore.IgnoreLis } // otherwise, create it - exclusion := ignore.Match(path.Path()) + exclusion, err := ignore.Match(path.Path()) + if err != nil { + return nil, err + } + child, err := CreateFile(root, path, exclusion) if err != nil { return nil, err diff --git a/internal/services/api/files/services.go b/internal/services/api/files/services.go index 71a698900..59ef7b3b1 100644 --- a/internal/services/api/files/services.go +++ b/internal/services/api/files/services.go @@ -38,7 +38,11 @@ type filesService struct { func (s filesService) GetFile(p util.Path) (*File, error) { p = p.Clean() - m := s.ignore.Match(p.String()) + m, err := s.ignore.Match(p.String()) + if err != nil { + return nil, err + } + file, err := CreateFile(s.root, p, m) if err != nil { return nil, err diff --git a/internal/services/api/post_publish.go b/internal/services/api/post_publish.go new file mode 100644 index 000000000..cfc8acbc8 --- /dev/null +++ b/internal/services/api/post_publish.go @@ -0,0 +1,46 @@ +package api + +// Copyright (C) 2023 by Posit Software, PBC. + +import ( + "encoding/json" + "net/http" + + "github.com/rstudio/connect-client/internal/accounts" + "github.com/rstudio/connect-client/internal/cli_types" + "github.com/rstudio/connect-client/internal/logging" + "github.com/rstudio/connect-client/internal/state" +) + +type PublishReponse struct { + LocalID state.LocalDeploymentID `json:"local_id"` // Unique ID of this publishing operation. Only valid for this run of the agent. +} + +type ManifestFilesPublisher interface { + PublishManifestFiles(lister accounts.AccountList, log logging.Logger) error +} + +func PostPublishHandlerFunc(publisher ManifestFilesPublisher, publishArgs *cli_types.PublishArgs, lister accounts.AccountList, log logging.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + localID, err := state.NewLocalID() + if err != nil { + InternalError(w, req, log, err) + return + } + publishArgs.State.LocalID = localID + response := PublishReponse{ + LocalID: localID, + } + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(response) + + go func() { + log = log.WithArgs("local_id", localID) + err := publisher.PublishManifestFiles(lister, log) + if err != nil { + log.Error("Deployment failed", "error", err.Error()) + } + }() + } +} diff --git a/internal/services/api/post_publish_test.go b/internal/services/api/post_publish_test.go new file mode 100644 index 000000000..04b50f11b --- /dev/null +++ b/internal/services/api/post_publish_test.go @@ -0,0 +1,70 @@ +package api + +// Copyright (C) 2023 by Posit Software, PBC. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rstudio/connect-client/internal/accounts" + "github.com/rstudio/connect-client/internal/cli_types" + "github.com/rstudio/connect-client/internal/logging" + "github.com/rstudio/connect-client/internal/state" + "github.com/rstudio/connect-client/internal/util/utiltest" + "github.com/stretchr/testify/suite" +) + +type PublishHandlerFuncSuite struct { + utiltest.Suite +} + +func TestPublishHandlerFuncSuite(t *testing.T) { + suite.Run(t, new(PublishHandlerFuncSuite)) +} + +type mockPublisher struct { + suite *PublishHandlerFuncSuite + args *cli_types.PublishArgs +} + +func (m *mockPublisher) PublishManifestFiles(lister accounts.AccountList, log logging.Logger) error { + m.suite.NotNil(m.args) + m.suite.NotNil(lister) + m.suite.NotNil(log) + m.suite.NotEqual(state.LocalDeploymentID(""), m.args.State.LocalID) + return nil +} + +func (s *PublishHandlerFuncSuite) TestPublishHandlerFunc() { + publishArgs := &cli_types.PublishArgs{ + State: state.NewDeployment(), + } + oldID := publishArgs.State.LocalID + log := logging.New() + + rec := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/publish", nil) + s.NoError(err) + + publisher := &mockPublisher{ + suite: s, + args: publishArgs, + } + lister := &accounts.MockAccountList{} + handler := PostPublishHandlerFunc(publisher, publishArgs, lister, log) + handler(rec, req) + + s.Equal(http.StatusAccepted, rec.Result().StatusCode) + s.Equal("application/json", rec.Header().Get("content-type")) + + res := &PublishReponse{} + dec := json.NewDecoder(rec.Body) + dec.DisallowUnknownFields() + s.NoError(dec.Decode(res)) + + s.NotEqual(state.LocalDeploymentID(""), publishArgs.State.LocalID) + s.NotEqual(oldID, publishArgs.State.LocalID) + s.Equal(publishArgs.State.LocalID, res.LocalID) +} diff --git a/internal/services/api/publish.go b/internal/services/api/publish.go deleted file mode 100644 index 126cbce3a..000000000 --- a/internal/services/api/publish.go +++ /dev/null @@ -1,26 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "net/http" - - "github.com/rstudio/connect-client/internal/accounts" - "github.com/rstudio/connect-client/internal/cli_types" - "github.com/rstudio/connect-client/internal/logging" - "github.com/rstudio/connect-client/internal/publish" -) - -func PostPublishHandlerFunc(publishArgs *cli_types.PublishArgs, lister accounts.AccountList, log logging.Logger) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - switch req.Method { - case http.MethodPost: - err := publish.PublishManifestFiles(publishArgs, lister, log) - if err != nil { - InternalError(w, req, log, err) - } - default: - return - } - } -} diff --git a/internal/services/local_token.go b/internal/services/local_token.go index 3e00f1185..90ae6a0ea 100644 --- a/internal/services/local_token.go +++ b/internal/services/local_token.go @@ -3,32 +3,15 @@ package services // Copyright (C) 2023 by Posit Software, PBC. import ( - "encoding/base64" - "strings" - "github.com/rstudio/connect-client/internal/util" ) type LocalToken string func NewLocalToken() (LocalToken, error) { - key, err := util.RandomBytes(32) - if err != nil { - return LocalToken(""), err - } - tokenString, err := toBase64(key) + str, err := util.RandomString(32) if err != nil { return LocalToken(""), err } - return LocalToken(tokenString), nil -} - -func toBase64(data []byte) (string, error) { - var writer strings.Builder - encoder := base64.NewEncoder(base64.RawURLEncoding, &writer) - _, err := encoder.Write(data) - if err != nil { - return "", err - } - return writer.String(), nil + return LocalToken(str), nil } diff --git a/internal/services/local_token_test.go b/internal/services/local_token_test.go new file mode 100644 index 000000000..557592570 --- /dev/null +++ b/internal/services/local_token_test.go @@ -0,0 +1,26 @@ +package services + +// Copyright (C) 2023 by Posit Software, PBC. + +import ( + "testing" + + "github.com/rstudio/connect-client/internal/util/utiltest" + "github.com/stretchr/testify/suite" +) + +type LocalTokenSuite struct { + utiltest.Suite +} + +func TestLocalTokenSuite(t *testing.T) { + suite.Run(t, new(LocalTokenSuite)) +} + +func (s *LocalTokenSuite) TestNewLocalToken() { + token1, err := NewLocalToken() + s.NoError(err) + token2, err := NewLocalToken() + s.NoError(err) + s.NotEqual(token1, token2) +} diff --git a/internal/services/middleware/log_request.go b/internal/services/middleware/log_request.go index b90a20b91..2e0172ce0 100644 --- a/internal/services/middleware/log_request.go +++ b/internal/services/middleware/log_request.go @@ -51,7 +51,7 @@ func LogRequest(msg string, log logging.Logger, next http.HandlerFunc) http.Hand next(writer, req) elapsedMs := time.Since(startTime).Milliseconds() - fieldLogger := log.With( + fieldLogger := log.WithArgs( "method", req.Method, "url", req.URL.String(), "elapsed_ms", elapsedMs, @@ -62,7 +62,7 @@ func LogRequest(msg string, log logging.Logger, next http.HandlerFunc) http.Hand ) correlationId := writer.Header().Get("X-Correlation-Id") if correlationId != "" { - fieldLogger = fieldLogger.With("X-Correlation-Id", correlationId) + fieldLogger = fieldLogger.WithArgs("X-Correlation-Id", correlationId) } fieldLogger.Info(msg) } diff --git a/internal/services/proxy/http_proxy.go b/internal/services/proxy/http_proxy.go index 76d7aee56..942cc3407 100644 --- a/internal/services/proxy/http_proxy.go +++ b/internal/services/proxy/http_proxy.go @@ -139,7 +139,7 @@ func (p *proxy) logHeader(msg string, header http.Header) { value = fmt.Sprintf("%v", values) } } - log = log.With(headerName(name), value) + log = log.WithArgs(headerName(name), value) } log.Debug(msg) } diff --git a/internal/services/ui/ui_service.go b/internal/services/ui/ui_service.go index 1784e35d5..522318a57 100644 --- a/internal/services/ui/ui_service.go +++ b/internal/services/ui/ui_service.go @@ -9,6 +9,7 @@ import ( "github.com/rstudio/connect-client/internal/accounts" "github.com/rstudio/connect-client/internal/cli_types" "github.com/rstudio/connect-client/internal/logging" + "github.com/rstudio/connect-client/internal/publish" "github.com/rstudio/connect-client/internal/services" "github.com/rstudio/connect-client/internal/services/api" "github.com/rstudio/connect-client/internal/services/api/deployments" @@ -18,6 +19,7 @@ import ( "github.com/rstudio/connect-client/web" "github.com/gorilla/mux" + "github.com/r3labs/sse/v2" "github.com/spf13/afero" ) @@ -30,9 +32,10 @@ func NewUIService( token services.LocalToken, fs afero.Fs, lister accounts.AccountList, - log logging.Logger) *api.Service { + log logging.Logger, + eventServer *sse.Server) *api.Service { - handler := RouterHandlerFunc(fs, publish, lister, log) + handler := RouterHandlerFunc(fs, publish, lister, log, eventServer) return api.NewService( publish.State, @@ -50,7 +53,7 @@ func NewUIService( ) } -func RouterHandlerFunc(afs afero.Fs, publishArgs *cli_types.PublishArgs, lister accounts.AccountList, log logging.Logger) http.HandlerFunc { +func RouterHandlerFunc(afs afero.Fs, publishArgs *cli_types.PublishArgs, lister accounts.AccountList, log logging.Logger, eventServer *sse.Server) http.HandlerFunc { deployment := publishArgs.State base := deployment.SourceDir @@ -63,6 +66,9 @@ func RouterHandlerFunc(afs afero.Fs, publishArgs *cli_types.PublishArgs, lister r.Handle(ToPath("accounts"), api.GetAccountsHandlerFunc(lister, log)). Methods(http.MethodGet) + // GET /api/events + r.HandleFunc(ToPath("events"), eventServer.ServeHTTP) + // GET /api/files r.Handle(ToPath("files"), api.GetFileHandlerFunc(base, filesService, pathsService, log)). Methods(http.MethodGet) @@ -76,7 +82,8 @@ func RouterHandlerFunc(afs afero.Fs, publishArgs *cli_types.PublishArgs, lister Methods(http.MethodPut) // POST /api/publish - r.Handle(ToPath("publish"), api.PostPublishHandlerFunc(publishArgs, lister, log)). + publisher := publish.New(publishArgs) + r.Handle(ToPath("publish"), api.PostPublishHandlerFunc(publisher, publishArgs, lister, log)). Methods(http.MethodPost) // GET / diff --git a/internal/state/deployment.go b/internal/state/deployment.go index 3eea9c204..db18d6cf2 100644 --- a/internal/state/deployment.go +++ b/internal/state/deployment.go @@ -23,7 +23,18 @@ type TargetID struct { DeployedAt types.NullTime `json:"deployed_at" kong:"-"` // Date/time bundle was deployed } +type LocalDeploymentID string + +func NewLocalID() (LocalDeploymentID, error) { + str, err := util.RandomString(16) + if err != nil { + return LocalDeploymentID(""), err + } + return LocalDeploymentID(str), nil +} + type Deployment struct { + LocalID LocalDeploymentID `json:"local_id" kong:"-"` // Unique ID of this publishing operation. Only valid for this run of the agent. SourceDir util.Path `json:"source_path" kong:"-"` // Absolute path to source directory being published Target TargetID `json:"target" kong:"embed"` // Identity of previous deployment Manifest bundles.Manifest `json:"manifest" kong:"embed"` // manifest.json content for this deployment diff --git a/internal/util/random.go b/internal/util/random.go index 1b0021401..70c447b96 100644 --- a/internal/util/random.go +++ b/internal/util/random.go @@ -4,6 +4,8 @@ package util import ( "crypto/rand" + "encoding/base64" + "strings" ) func RandomBytes(n int) ([]byte, error) { @@ -11,3 +13,26 @@ func RandomBytes(n int) ([]byte, error) { _, err := rand.Read(buf) return buf, err } + +func RandomString(n int) (string, error) { + // Base64 encoding of bytes->string expands length by 1/3 + key, err := RandomBytes((n * 3) / 4) + if err != nil { + return "", err + } + tokenString, err := toBase64(key) + if err != nil { + return "", err + } + return tokenString, nil +} + +func toBase64(data []byte) (string, error) { + var writer strings.Builder + encoder := base64.NewEncoder(base64.RawURLEncoding, &writer) + _, err := encoder.Write(data) + if err != nil { + return "", err + } + return writer.String(), nil +} diff --git a/internal/util/random_test.go b/internal/util/random_test.go index 330fc33d1..6ae6d8737 100644 --- a/internal/util/random_test.go +++ b/internal/util/random_test.go @@ -18,7 +18,31 @@ func TestRandomSuite(t *testing.T) { } func (s *RandomSuite) TestRandomBytes() { - r, err := RandomBytes(32) - s.Nil(err) - s.Len(r, 32) + r1, err := RandomBytes(32) + s.NoError(err) + s.Len(r1, 32) + + r2, err := RandomBytes(32) + s.NoError(err) + s.Len(r2, 32) + s.NotEqual(r1, r2) + + r3, err := RandomBytes(12) + s.NoError(err) + s.Len(r3, 12) +} + +func (s *RandomSuite) TestRandomString() { + r1, err := RandomString(32) + s.NoError(err) + s.Len(r1, 32) + + r2, err := RandomString(32) + s.NoError(err) + s.Len(r2, 32) + s.NotEqual(r1, r2) + + r3, err := RandomString(12) + s.NoError(err) + s.Len(r3, 12) } diff --git a/test/sample-content/fastapi-simple/.gitignore b/test/sample-content/fastapi-simple/.gitignore new file mode 100644 index 000000000..1d17dae13 --- /dev/null +++ b/test/sample-content/fastapi-simple/.gitignore @@ -0,0 +1 @@ +.venv diff --git a/web/.gitignore b/web/.gitignore index a590335c7..0a05d9fab 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -26,3 +26,4 @@ dist-ssr .nyc_output coverage/ +vite.config.ts.* \ No newline at end of file diff --git a/web/public/images/files-icon.jpg b/web/public/images/files-icon.jpg deleted file mode 100644 index 6b7fae2a3..000000000 Binary files a/web/public/images/files-icon.jpg and /dev/null differ diff --git a/web/public/images/info.png b/web/public/images/info.png deleted file mode 100644 index 26d6695ba..000000000 Binary files a/web/public/images/info.png and /dev/null differ diff --git a/web/public/images/posit-logo-only-unofficial.svg b/web/public/images/posit-logo-only-unofficial.svg deleted file mode 100644 index 1110d5d6f..000000000 --- a/web/public/images/posit-logo-only-unofficial.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web/public/images/python-logo-only.svg b/web/public/images/python-logo-only.svg deleted file mode 100644 index 467b07b26..000000000 --- a/web/public/images/python-logo-only.svg +++ /dev/null @@ -1,265 +0,0 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/web/public/images/settings.png b/web/public/images/settings.png deleted file mode 100644 index 1173e4182..000000000 Binary files a/web/public/images/settings.png and /dev/null differ diff --git a/web/src/App.vue b/web/src/App.vue index cd4754320..654d05888 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -9,66 +9,83 @@ elevated class="bg-primary text-white" > - + + - -
- What would you like to be published and how? -
- - - - - - - - - - - - -
+ +
diff --git a/web/src/api/resources/EventStream.ts b/web/src/api/resources/EventStream.ts new file mode 100644 index 000000000..f286aad1f --- /dev/null +++ b/web/src/api/resources/EventStream.ts @@ -0,0 +1,247 @@ +// Copyright (C) 2023 by Posit Software, PBC. + +import camelcaseKeys from 'camelcase-keys'; + +import { + OnMessageEventSourceCallback, + MethodResult, + EventStatus, + EventStreamMessage, + isEventStreamMessage, + EventSubscriptionTarget, + CallbackQueueEntry, +} from 'src/api/types/events.ts'; + +export class EventStream { + private eventSource = null; + private isOpen = false; + private lastError = null; + private debugEnabled = false; + + private subscriptions = []; + + private logMsg(msg: string) { + if (this.debugEnabled) { + console.log(`DEBUG: ${msg}`); + } + } + + private logError(msg: string, error: MethodResult): MethodResult { + this.logMsg(`${msg}: error = ${error?.error}`); + return error; + } + + private matchEvent( + subscriptionType: EventSubscriptionTarget, + incomingEventType: EventSubscriptionTarget + ) { + this.logMsg(`MatchEvent: subscription type: ${subscriptionType}, incomingType: ${incomingEventType}`); + if (subscriptionType.indexOf('*') === 0) { + this.logMsg('matched on *'); + return true; + } + const wildCardIndex = subscriptionType.indexOf('/*'); + // Does the wildcard live at the very end of the subscription type? + if (wildCardIndex > 0 && subscriptionType.length === wildCardIndex + 2) { + const basePath = subscriptionType.substring(0, wildCardIndex); + if (incomingEventType.indexOf(basePath) === 0) { + this.logMsg('matched on start of string'); + return true; + } + } + // Are we using a glob, which is meant to be in the middle of two strings + // which need to be matched + const globIndex = subscriptionType.indexOf('/**/'); + if (globIndex > 0) { + // split our subscription type string into two parts (before and after the glob characters) + const parts = subscriptionType.split('/**/'); + // to match, we must make sure we find that the incoming event type starts + // exactly with our first part and ends with exactly our second part, regardless of how + // many characters in the incoming event type are "consumed" by our glob query. + if ( + incomingEventType.indexOf(parts[0]) === 0 && + incomingEventType.indexOf(parts[1]) === incomingEventType.length - parts[1].length + ) { + this.logMsg('matched on glob'); + return true; + } + } + + // no wild-card. Must match exactly + this.logMsg(`attempt to match on exact string. Result = ${subscriptionType === incomingEventType}`); + return subscriptionType === incomingEventType; + } + + private dispatchMessage(msg: EventStreamMessage) { + let numMatched = 0; + this.subscriptions.forEach(entry => { + if (this.matchEvent(entry.eventType, msg.type)) { + numMatched++; + entry.callback(msg); + } + }); + if (numMatched === 0 && msg.type !== 'errors/open') { + this.logMsg(`WARNING! No subscriber/handler found for msg: ${JSON.stringify}`); + this.dispatchMessage({ + type: 'errors/unknownEvent', + time: new Date().toString(), + data: { + event: msg, + }, + }); + } + } + + private onRawOpenCallback() { + this.logMsg(`received RawOpenCallback`); + this.isOpen = true; + this.dispatchMessage({ + type: 'open/sse', + time: new Date().toString(), + data: {}, + }); + } + + private onErrorRawCallback(e: Event) { + // errors are fatal, connection is down. + // not receiving anything of value from calling parameters. only : {"isTrusted":true} + this.logMsg(`received ErrorRawCallback: ${JSON.stringify(e)}`); + this.isOpen = false; + this.lastError = `unknown error with connection ${Date.now()}`; + const now = new Date(); + this.dispatchMessage({ + type: 'errors/open', + time: now.toString(), + data: { msg: `${this.lastError}` }, + }); + } + + private parseMessageData(data: string) : EventStreamMessage | null { + const rawObj = JSON.parse(data); + const obj = camelcaseKeys(rawObj); + if (isEventStreamMessage(obj)) { + return obj; + } + return null; + } + + private onMessageRawCallback(msg: MessageEvent) { + this.logMsg(`received MessageRawCallback (for real): ${msg.data}`); + const parsed = this.parseMessageData(msg.data); + if (!parsed) { + const errorMsg = `Invalid EventStreamMessage received: ${msg.data}`; + const now = new Date(); + this.dispatchMessage({ + type: 'errors/open', + time: now.toString(), + data: { msg: `${errorMsg}` }, + }); + return; + } + this.logMsg(`Received event type = ${parsed.type}`); + this.dispatchMessage(parsed); + } + + private initializeConnection(url: string, withCredentials: boolean): MethodResult { + this.logMsg(`initializing connection to ${url}, with credentials: ${withCredentials}`); + this.eventSource = new EventSource(url, { withCredentials: withCredentials }); + this.eventSource.onopen = () => this.onRawOpenCallback(); + // nothing good seems to come with the error data. Only get {"isTrusted":true} + this.eventSource.onerror = (e) => this.onErrorRawCallback(e); + this.eventSource.onmessage = (msg: MessageEvent) => this.onMessageRawCallback(msg); + return { + ok: true, + }; + } + + public open(url: string, withCredentials = false): MethodResult { + this.logMsg(`opening connection ${url}, with credentials: ${withCredentials}}`); + if (this.isOpen) { + return this.logError( + `failure opening connection`, + { + ok: false, + error: `EventStream instance has already been initialized to ${url}.`, + } + ); + } + if (!url) { + return this.logError( + `failure opening connection`, + { + ok: false, + error: `URL parameter must be a non-empty string.`, + } + ); + } + return this.initializeConnection(url, withCredentials); + } + + public close(): MethodResult { + if (this.isOpen && this.eventSource !== null) { + this.eventSource.close(); + this.eventSource = null; + this.isOpen = false; + return { + ok: true, + }; + } + return this.logError( + `failure closing connection`, + { + ok: false, + error: `EventSource is not open.`, + } + ); + } + + public addEventMonitorCallback( + targets: EventSubscriptionTarget[], + cb: OnMessageEventSourceCallback + ) { + for (const t in targets) { + this.subscriptions.push({ + eventType: targets[t], + callback: cb, + }); + } + } + + public delEventFilterCallback(cb: OnMessageEventSourceCallback) { + let found = false; + let index = -1; + // We may have multiple events being delivered to same callback + // so we have to search until we do not find anything + do { + index = this.subscriptions.findIndex(entry => entry.callback === cb); + if (index >= 0) { + this.subscriptions.splice(index, 1); + found = true; + } + } while (index >= 0); + if (found) { + this.logMsg(`delEventFilterCallback found at least one match!`); + } else { + this.logMsg(`delEventFilterCallback did NOT match any subcription callbacks!`); + } + return found; + } + + public status(): EventStatus { + return { + withCredentials: this.eventSource?.withCredentials, + readyState: this.eventSource?.readyState, + url: this.eventSource ? this.eventSource.url : null, + lastError: this.lastError, + isOpen: this.isOpen, + eventSource: this.eventSource ? 'eventSource has been initialized' : 'eventSource not yet initialized', + }; + } + + public setDebugMode(val: boolean) { + this.debugEnabled = val; + if (val) { + this.logMsg(`debug logging is enabled!`); + } + } +} diff --git a/web/src/api/types/deployments.ts b/web/src/api/types/deployments.ts index 506690eaf..b8bbc92f9 100644 --- a/web/src/api/types/deployments.ts +++ b/web/src/api/types/deployments.ts @@ -22,3 +22,5 @@ export type Deployment = { connect: ConnectDeployment; pythonRequirements: string[]; } + +export type DeploymentModeType = 'new' | 'update'; diff --git a/web/src/api/types/events.ts b/web/src/api/types/events.ts new file mode 100644 index 000000000..c882308dc --- /dev/null +++ b/web/src/api/types/events.ts @@ -0,0 +1,82 @@ +// Copyright (C) 2023 by Posit Software, PBC. + +export enum EventSourceReadyState { + CONNECTING = 0, + OPEN = 1, + CLOSED = 2, +} + +export enum EventStreamMessageType { + ERROR = 'error', + LOG = 'log', +} + +export type EventStreamMessage = { + type: EventSubscriptionTarget, + time: string, + data: object, +} + +export function isEventStreamMessage(o: object): o is EventStreamMessage { + return ( + 'type' in o && + 'time' in o && + 'data' in o + ); +} + +export type OnMessageEventSourceCallback = (msg: EventStreamMessage) => void; + +export type MethodResult = { + ok: boolean, + error?: string, +} + +export type EventStatus = { + isOpen?: boolean, + eventSource: string, + withCredentials?: boolean, + readyState?: EventSourceReadyState, + url: string | null, + lastError: string | null, +} + +export type CallbackQueueEntry = { + eventType: EventSubscriptionTarget, + callback: OnMessageEventSourceCallback, +} + +export type EventSubscriptionTarget = + '*' | // all events + + 'agent/log' | // agent console log messages + + 'errors/*' | // all errors + 'errors/sse' | + 'errors/open' | + 'errors/unknownEvent' | + + 'open/*' | // open events + 'open/sse' | + + 'publish/createBundle/start' | + 'publish/createBundle/success' | + + 'publish/createDeployment/start' | + 'publish/createDeployment/success' | + + 'publish/uploadBundle/start' | + 'publish/uploadBundle/success' | + + 'publish/deployBundle/start' | + 'publish/deployBundle/success' | + + 'publish/**/log' | + + 'publish/restorePythonEnv/log' | + 'publish/restorePythonEnv/success' | + + 'publish/runContent/log' | + 'publish/runContent/success' | + + 'publish/success'; diff --git a/web/src/components/AppMenu.vue b/web/src/components/AppMenu.vue new file mode 100644 index 000000000..f24061e0e --- /dev/null +++ b/web/src/components/AppMenu.vue @@ -0,0 +1,64 @@ + + + diff --git a/web/src/components/panels/AdvancedSettings.vue b/web/src/components/configurePublish/AdvancedSettings.vue similarity index 55% rename from web/src/components/panels/AdvancedSettings.vue rename to web/src/components/configurePublish/AdvancedSettings.vue index 02adf2774..6364680dc 100644 --- a/web/src/components/panels/AdvancedSettings.vue +++ b/web/src/components/configurePublish/AdvancedSettings.vue @@ -3,13 +3,16 @@ diff --git a/web/src/components/panels/CommonSettings.vue b/web/src/components/configurePublish/CommonSettings.vue similarity index 51% rename from web/src/components/panels/CommonSettings.vue rename to web/src/components/configurePublish/CommonSettings.vue index ea99cdb2e..23f8444da 100644 --- a/web/src/components/panels/CommonSettings.vue +++ b/web/src/components/configurePublish/CommonSettings.vue @@ -3,13 +3,16 @@ diff --git a/web/src/components/configurePublish/ConfigurePublish.vue b/web/src/components/configurePublish/ConfigurePublish.vue new file mode 100644 index 000000000..365dad945 --- /dev/null +++ b/web/src/components/configurePublish/ConfigurePublish.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/web/src/components/configurePublish/DeploymentMode.vue b/web/src/components/configurePublish/DeploymentMode.vue new file mode 100644 index 000000000..3e8411589 --- /dev/null +++ b/web/src/components/configurePublish/DeploymentMode.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/web/src/components/panels/DestinationTarget.vue b/web/src/components/configurePublish/DestinationTarget.vue similarity index 72% rename from web/src/components/panels/DestinationTarget.vue rename to web/src/components/configurePublish/DestinationTarget.vue index 322de1ae5..981169d72 100644 --- a/web/src/components/panels/DestinationTarget.vue +++ b/web/src/components/configurePublish/DestinationTarget.vue @@ -4,8 +4,10 @@ + TODO: select from previous deployments or add to existing or new targets @@ -13,7 +15,9 @@ +defineProps({ + title: { type: String, required: true }, + subtitle: { type: String, required: false, default: undefined }, + tooltip: { type: String, required: false, default: undefined }, + defaultOpen: { type: Boolean, required: false, default: false }, + expandIcon: { type: String, required: false, default: undefined }, + group: { type: String, required: false, default: undefined }, +}); + diff --git a/web/src/components/PublishProcess.vue b/web/src/components/configurePublish/PublishingHeader.vue similarity index 58% rename from web/src/components/PublishProcess.vue rename to web/src/components/configurePublish/PublishingHeader.vue index 40a231384..bd1af0701 100644 --- a/web/src/components/PublishProcess.vue +++ b/web/src/components/configurePublish/PublishingHeader.vue @@ -1,12 +1,24 @@