Compare commits
12
Commits
test
...
rc-core-log
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48da73ad5a | ||
|
|
3cd41095b4 | ||
|
|
4bd9dd6734 | ||
|
|
0e3c9bb755 | ||
|
|
7e18810db8 | ||
|
|
846836adc0 | ||
|
|
cd54853ac7 | ||
|
|
61ce03ac02 | ||
|
|
818456978e | ||
|
|
63e3d6f2d7 | ||
|
|
02b3419f76 | ||
|
|
d148f5c5ed |
No files matched your search
+6
-1
@@ -208,7 +208,12 @@ func doCall(ctx context.Context, path string, in rc.Params) (out rc.Params, err
|
||||
}
|
||||
_, out, err := jobs.NewJob(ctx, call.Fn, in)
|
||||
if err != nil {
|
||||
return errorf(http.StatusInternalServerError, path, "loopback: call failed: %w", err)
|
||||
out, err = errorf(http.StatusInternalServerError, path, "loopback: call failed: %w", err)
|
||||
var logsErr *rc.ErrorWithLogs
|
||||
if errors.As(err, &logsErr) {
|
||||
out["_logs"] = logsErr.Logs
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
// Reshape (serialize then deserialize) the data so it is in the form expected
|
||||
err = rc.Reshape(&out, out)
|
||||
|
||||
@@ -49,6 +49,10 @@ See the [rc documentation](/rc/) for more info on the rc flags.
|
||||
rc.Opt.Files = args[0]
|
||||
}
|
||||
|
||||
// Print the stats periodically if --stats is in use, as
|
||||
// the serve and mount commands do
|
||||
defer cmd.StartStats()()
|
||||
|
||||
s, err := rcserver.Start(context.Background(), &rc.Opt)
|
||||
if err != nil {
|
||||
fs.Fatalf(nil, "Failed to start remote control: %v", err)
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
---
|
||||
title: "WebDAV"
|
||||
description: "Rclone docs for WebDAV"
|
||||
versionIntroduced: "v1.39"
|
||||
---
|
||||
|
||||
# WebDAV
|
||||
|
||||
Paths are specified as `remote:path`
|
||||
|
||||
Paths may be as deep as required, e.g. `remote:directory/subdirectory`.
|
||||
|
||||
## Configuration
|
||||
|
||||
To configure the WebDAV remote you will need to have a URL for it, and
|
||||
a username and password. If you know what kind of system you are
|
||||
connecting to then rclone can enable extra features.
|
||||
|
||||
Here is an example of how to make a remote called `remote`. First run:
|
||||
|
||||
```console
|
||||
rclone config
|
||||
```
|
||||
|
||||
This will guide you through an interactive setup process:
|
||||
|
||||
```text
|
||||
No remotes found, make a new one?
|
||||
n) New remote
|
||||
s) Set configuration password
|
||||
q) Quit config
|
||||
n/s/q> n
|
||||
name> remote
|
||||
Type of storage to configure.
|
||||
Choose a number from below, or type in your own value
|
||||
[snip]
|
||||
XX / WebDAV
|
||||
\ "webdav"
|
||||
[snip]
|
||||
Storage> webdav
|
||||
URL of http host to connect to
|
||||
Choose a number from below, or type in your own value
|
||||
1 / Connect to example.com
|
||||
\ "https://example.com"
|
||||
url> https://example.com/remote.php/webdav/
|
||||
Name of the WebDAV site/service/software you are using
|
||||
Choose a number from below, or type in your own value
|
||||
1 / Fastmail Files
|
||||
\ (fastmail)
|
||||
2 / Nextcloud
|
||||
\ (nextcloud)
|
||||
3 / Owncloud
|
||||
\ (owncloud)
|
||||
4 / Sharepoint Online, authenticated by Microsoft account
|
||||
\ (sharepoint)
|
||||
5 / Sharepoint with NTLM authentication, usually self-hosted or on-premises
|
||||
\ (sharepoint-ntlm)
|
||||
6 / rclone WebDAV server to serve a remote over HTTP via the WebDAV protocol
|
||||
\ (rclone)
|
||||
7 / Other site/service or software
|
||||
\ (other)
|
||||
vendor> 2
|
||||
User name
|
||||
user> user
|
||||
Password.
|
||||
y) Yes type in my own password
|
||||
g) Generate random password
|
||||
n) No leave this optional password blank
|
||||
y/g/n> y
|
||||
Enter the password:
|
||||
password:
|
||||
Confirm the password:
|
||||
password:
|
||||
Bearer token instead of user/pass (e.g. a Macaroon)
|
||||
bearer_token>
|
||||
Remote config
|
||||
Configuration complete.
|
||||
Options:
|
||||
- type: webdav
|
||||
- url: https://example.com/remote.php/webdav/
|
||||
- vendor: nextcloud
|
||||
- user: user
|
||||
- pass: *** ENCRYPTED ***
|
||||
- bearer_token:
|
||||
Keep this "remote" remote?
|
||||
y) Yes this is OK
|
||||
e) Edit this remote
|
||||
d) Delete this remote
|
||||
y/e/d> y
|
||||
```
|
||||
|
||||
Once configured you can then use `rclone` like this (replace `remote` with the
|
||||
name you gave your remote):
|
||||
|
||||
List directories in top level of your WebDAV
|
||||
|
||||
```console
|
||||
rclone lsd remote:
|
||||
```
|
||||
|
||||
List all the files in your WebDAV
|
||||
|
||||
```console
|
||||
rclone ls remote:
|
||||
```
|
||||
|
||||
To copy a local directory to an WebDAV directory called backup
|
||||
|
||||
```console
|
||||
rclone copy /home/source remote:backup
|
||||
```
|
||||
|
||||
### Modification times and hashes
|
||||
|
||||
Plain WebDAV does not support modified times. However when used with
|
||||
Fastmail Files, ownCloud or Nextcloud rclone will support modified times.
|
||||
|
||||
Likewise plain WebDAV does not support hashes, however when used with
|
||||
Fastmail Files, ownCloud or Nextcloud rclone will support SHA1 and MD5 hashes.
|
||||
Depending on the exact version of ownCloud or Nextcloud hashes may
|
||||
appear on all objects, or only on objects which had a hash uploaded
|
||||
with them. With Nextcloud, rclone asks the server to calculate the SHA1
|
||||
of uploads which had no hash to send, such as streamed uploads, and
|
||||
after setting the modification time, which discards the stored hash.
|
||||
|
||||
<!-- autogenerated options start - DO NOT EDIT - instead edit fs.RegInfo in backend/webdav/webdav.go and run make backenddocs to verify --> <!-- markdownlint-disable-line line-length -->
|
||||
### Standard options
|
||||
|
||||
Here are the Standard options specific to webdav (WebDAV).
|
||||
|
||||
#### --webdav-url
|
||||
|
||||
URL of http host to connect to.
|
||||
|
||||
E.g. https://example.com.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: url
|
||||
- Env Var: RCLONE_WEBDAV_URL
|
||||
- Type: string
|
||||
- Required: true
|
||||
|
||||
#### --webdav-vendor
|
||||
|
||||
Name of the WebDAV site/service/software you are using.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: vendor
|
||||
- Env Var: RCLONE_WEBDAV_VENDOR
|
||||
- Type: string
|
||||
- Required: false
|
||||
- Examples:
|
||||
- "fastmail"
|
||||
- Fastmail Files
|
||||
- "nextcloud"
|
||||
- Nextcloud
|
||||
- "owncloud"
|
||||
- Owncloud 10 PHP based WebDAV server
|
||||
- "infinitescale"
|
||||
- ownCloud Infinite Scale
|
||||
- "sharepoint"
|
||||
- Sharepoint Online, authenticated by Microsoft account
|
||||
- "sharepoint-ntlm"
|
||||
- Sharepoint with NTLM authentication, usually self-hosted or on-premises
|
||||
- "rclone"
|
||||
- rclone WebDAV server to serve a remote over HTTP via the WebDAV protocol
|
||||
- "other"
|
||||
- Other site/service or software
|
||||
|
||||
#### --webdav-user
|
||||
|
||||
User name.
|
||||
|
||||
In case NTLM authentication is used, the username should be in the format 'Domain\User'.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: user
|
||||
- Env Var: RCLONE_WEBDAV_USER
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
#### --webdav-pass
|
||||
|
||||
Password.
|
||||
|
||||
**NB** Input to this must be obscured - see [rclone obscure](/commands/rclone_obscure/).
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: pass
|
||||
- Env Var: RCLONE_WEBDAV_PASS
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
#### --webdav-bearer-token
|
||||
|
||||
Bearer token instead of user/pass (e.g. a Macaroon).
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: bearer_token
|
||||
- Env Var: RCLONE_WEBDAV_BEARER_TOKEN
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
### Advanced options
|
||||
|
||||
Here are the Advanced options specific to webdav (WebDAV).
|
||||
|
||||
#### --webdav-bearer-token-command
|
||||
|
||||
Command to run to get a bearer token.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: bearer_token_command
|
||||
- Env Var: RCLONE_WEBDAV_BEARER_TOKEN_COMMAND
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
#### --webdav-encoding
|
||||
|
||||
The encoding for the backend.
|
||||
|
||||
See the [encoding section in the overview](/overview/#encoding) for more info.
|
||||
|
||||
Default encoding is Slash,LtGt,DoubleQuote,Colon,Question,Asterisk,Pipe,Hash,Percent,BackSlash,Del,Ctl,LeftSpace,LeftTilde,RightSpace,RightPeriod,InvalidUtf8 for sharepoint-ntlm or identity otherwise.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: encoding
|
||||
- Env Var: RCLONE_WEBDAV_ENCODING
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
#### --webdav-headers
|
||||
|
||||
Set HTTP headers for all transactions.
|
||||
|
||||
Use this to set additional HTTP headers for all transactions
|
||||
|
||||
The input format is comma separated list of key,value pairs. Standard
|
||||
[CSV encoding](https://godoc.org/encoding/csv) may be used.
|
||||
|
||||
For example, to set a Cookie use 'Cookie,name=value', or '"Cookie","name=value"'.
|
||||
|
||||
You can set multiple headers, e.g. '"Cookie","name=value","Authorization","xxx"'.
|
||||
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: headers
|
||||
- Env Var: RCLONE_WEBDAV_HEADERS
|
||||
- Type: CommaSepList
|
||||
- Default:
|
||||
|
||||
#### --webdav-pacer-min-sleep
|
||||
|
||||
Minimum time to sleep between API calls.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: pacer_min_sleep
|
||||
- Env Var: RCLONE_WEBDAV_PACER_MIN_SLEEP
|
||||
- Type: Duration
|
||||
- Default: 10ms
|
||||
|
||||
#### --webdav-nextcloud-chunk-size
|
||||
|
||||
Nextcloud upload chunk size.
|
||||
|
||||
We recommend configuring your NextCloud instance to increase the max chunk size to 1 GB for better upload performances.
|
||||
See https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/big_file_upload_configuration.html#adjust-chunk-size-on-nextcloud-side
|
||||
|
||||
Set to 0 to disable chunked uploading.
|
||||
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: nextcloud_chunk_size
|
||||
- Env Var: RCLONE_WEBDAV_NEXTCLOUD_CHUNK_SIZE
|
||||
- Type: SizeSuffix
|
||||
- Default: 10Mi
|
||||
|
||||
#### --webdav-owncloud-exclude-shares
|
||||
|
||||
Exclude ownCloud shares
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: owncloud_exclude_shares
|
||||
- Env Var: RCLONE_WEBDAV_OWNCLOUD_EXCLUDE_SHARES
|
||||
- Type: bool
|
||||
- Default: false
|
||||
|
||||
#### --webdav-owncloud-exclude-mounts
|
||||
|
||||
Exclude ownCloud mounted storages
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: owncloud_exclude_mounts
|
||||
- Env Var: RCLONE_WEBDAV_OWNCLOUD_EXCLUDE_MOUNTS
|
||||
- Type: bool
|
||||
- Default: false
|
||||
|
||||
#### --webdav-unix-socket
|
||||
|
||||
Path to a unix domain socket to dial to, instead of opening a TCP connection directly
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: unix_socket
|
||||
- Env Var: RCLONE_WEBDAV_UNIX_SOCKET
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
#### --webdav-auth-redirect
|
||||
|
||||
Preserve authentication on redirect.
|
||||
|
||||
If the server redirects rclone to a new domain when it is trying to
|
||||
read a file then normally rclone will drop the Authorization: header
|
||||
from the request.
|
||||
|
||||
This is standard security practice to avoid sending your credentials
|
||||
to an unknown webserver.
|
||||
|
||||
However this is desirable in some circumstances. If you are getting
|
||||
an error like "401 Unauthorized" when rclone is attempting to read
|
||||
files from the webdav server then you can try this option.
|
||||
|
||||
Note that enabling this also permits sending your credentials over a
|
||||
plaintext HTTP connection if the server redirects from HTTPS to HTTP,
|
||||
which rclone otherwise refuses to do.
|
||||
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: auth_redirect
|
||||
- Env Var: RCLONE_WEBDAV_AUTH_REDIRECT
|
||||
- Type: bool
|
||||
- Default: false
|
||||
|
||||
#### --webdav-description
|
||||
|
||||
Description of the remote.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: description
|
||||
- Env Var: RCLONE_WEBDAV_DESCRIPTION
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
<!-- autogenerated options stop -->
|
||||
|
||||
## Provider notes
|
||||
|
||||
See below for notes on specific providers.
|
||||
|
||||
### Fastmail Files
|
||||
|
||||
Use `https://webdav.fastmail.com/` or a subdirectory as the URL,
|
||||
and your Fastmail email `username@domain.tld` as the username.
|
||||
Follow [this documentation](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords)
|
||||
to create an app password with access to `Files (WebDAV)` and use
|
||||
this as the password.
|
||||
|
||||
Fastmail supports modified times using the `X-OC-Mtime` header.
|
||||
|
||||
### ownCloud
|
||||
|
||||
Click on the settings cog in the bottom right of the page and this
|
||||
will show the WebDAV URL that rclone needs in the config step. It
|
||||
will look something like `https://example.com/remote.php/webdav/`.
|
||||
|
||||
ownCloud supports modified times using the `X-OC-Mtime` header.
|
||||
|
||||
### Nextcloud
|
||||
|
||||
This is configured in an identical way to ownCloud. Note that
|
||||
Nextcloud initially did not support streaming of files (`rcat`) whereas
|
||||
ownCloud did, but [this](https://github.com/nextcloud/nextcloud-snap/issues/365)
|
||||
seems to be fixed as of 2020-11-27 (tested with rclone v1.53.1 and Nextcloud
|
||||
Server v19).
|
||||
|
||||
### ownCloud Infinite Scale
|
||||
|
||||
The WebDAV URL for Infinite Scale can be found in the details panel of
|
||||
any space in Infinite Scale, if the display was enabled in the personal
|
||||
settings of the user through a checkbox there.
|
||||
|
||||
Infinite Scale works with the chunking [tus](https://tus.io) upload protocol.
|
||||
The chunk size is currently fixed 10 MB.
|
||||
|
||||
### Sharepoint Online
|
||||
|
||||
Rclone can be used with Sharepoint provided by OneDrive for Business
|
||||
or Office365 Education Accounts.
|
||||
This feature is only needed for a few of these Accounts,
|
||||
mostly Office365 Education ones. These accounts are sometimes not
|
||||
verified by the domain owner [github#1975](https://github.com/rclone/rclone/issues/1975)
|
||||
|
||||
This means that these accounts can't be added using the official
|
||||
API (other Accounts should work with the "onedrive" option). However,
|
||||
it is possible to access them using webdav.
|
||||
|
||||
To use a sharepoint remote with rclone, add it like this:
|
||||
First, you need to get your remote's URL:
|
||||
|
||||
- Go [here](https://onedrive.live.com/about/en-us/signin/)
|
||||
to open your OneDrive or to sign in
|
||||
- Now take a look at your address bar, the URL should look like this:
|
||||
`https://[YOUR-DOMAIN]-my.sharepoint.com/personal/[YOUR-EMAIL]/_layouts/15/onedrive.aspx`
|
||||
|
||||
You'll only need this URL up to the email address. After that, you'll
|
||||
most likely want to add "/Documents". That subdirectory contains
|
||||
the actual data stored on your OneDrive.
|
||||
|
||||
Add the remote to rclone like this:
|
||||
Configure the `url` as `https://[YOUR-DOMAIN]-my.sharepoint.com/personal/[YOUR-EMAIL]/Documents`
|
||||
and use your normal account email and password for `user` and `pass`.
|
||||
If you have 2FA enabled, you have to generate an app password.
|
||||
Set the `vendor` to `sharepoint`.
|
||||
|
||||
Your config file should look like this:
|
||||
|
||||
```ini
|
||||
[sharepoint]
|
||||
type = webdav
|
||||
url = https://[YOUR-DOMAIN]-my.sharepoint.com/personal/[YOUR-EMAIL]/Documents
|
||||
vendor = sharepoint
|
||||
user = YourEmailAddress
|
||||
pass = encryptedpassword
|
||||
```
|
||||
|
||||
### Sharepoint with NTLM Authentication
|
||||
|
||||
Use this option in case your (hosted) Sharepoint is not tied to OneDrive
|
||||
accounts and uses NTLM authentication.
|
||||
|
||||
To get the `url` configuration, similarly to the above, first navigate to the
|
||||
desired directory in your browser to get the URL, then strip everything after
|
||||
the name of the opened directory.
|
||||
|
||||
Example:
|
||||
If the URL is:
|
||||
<https://example.sharepoint.com/sites/12345/Documents/Forms/AllItems.aspx>
|
||||
|
||||
The configuration to use would be:
|
||||
<https://example.sharepoint.com/sites/12345/Documents>
|
||||
|
||||
Set the `vendor` to `sharepoint-ntlm`.
|
||||
|
||||
NTLM uses domain and user name combination for authentication,
|
||||
set `user` to `DOMAIN\username`.
|
||||
|
||||
Your config file should look like this:
|
||||
|
||||
```ini
|
||||
[sharepoint]
|
||||
type = webdav
|
||||
url = https://[YOUR-DOMAIN]/some-path-to/Documents
|
||||
vendor = sharepoint-ntlm
|
||||
user = DOMAIN\user
|
||||
pass = encryptedpassword
|
||||
```
|
||||
|
||||
#### Required Flags for SharePoint
|
||||
|
||||
As SharePoint does some special things with uploaded documents, you won't be
|
||||
able to use the documents size or the documents hash to compare if a file has
|
||||
been changed since the upload / which file is newer.
|
||||
|
||||
For Rclone calls copying files (especially Office files such as .docx, .xlsx, etc.)
|
||||
from/to SharePoint (like copy, sync, etc.), you should append these flags to ensure
|
||||
Rclone uses the "Last Modified" datetime property to compare your documents:
|
||||
|
||||
```text
|
||||
--ignore-size --ignore-checksum --update
|
||||
```
|
||||
|
||||
## Rclone
|
||||
|
||||
Use this option if you are hosting remotes over WebDAV provided by rclone.
|
||||
Read [rclone serve webdav](commands/rclone_serve_webdav/) for more details.
|
||||
|
||||
rclone serve supports modified times using the `X-OC-Mtime` header.
|
||||
|
||||
### dCache
|
||||
|
||||
dCache is a storage system that supports many protocols and
|
||||
authentication/authorisation schemes. For WebDAV clients, it allows
|
||||
users to authenticate with username and password (BASIC), X.509,
|
||||
Kerberos, and various bearer tokens, including
|
||||
[Macaroons](https://www.dcache.org/manuals/workshop-2017-05-29-Umea/000-Final/anupam_macaroons_v02.pdf)
|
||||
and [OpenID-Connect](https://en.wikipedia.org/wiki/OpenID_Connect)
|
||||
access tokens.
|
||||
|
||||
Configure as normal using the `other` type. Don't enter a username or
|
||||
password, instead enter your Macaroon as the `bearer_token`.
|
||||
|
||||
The config will end up looking something like this.
|
||||
|
||||
```ini
|
||||
[dcache]
|
||||
type = webdav
|
||||
url = https://dcache...
|
||||
vendor = other
|
||||
user =
|
||||
pass =
|
||||
bearer_token = your-macaroon
|
||||
```
|
||||
|
||||
There is a [script](https://github.com/sara-nl/GridScripts/blob/master/get-macaroon)
|
||||
that obtains a Macaroon from a dCache WebDAV endpoint, and creates an rclone config
|
||||
file.
|
||||
|
||||
Macaroons may also be obtained from the dCacheView
|
||||
web-browser/JavaScript client that comes with dCache.
|
||||
|
||||
### OpenID-Connect
|
||||
|
||||
dCache also supports authenticating with OpenID-Connect access tokens.
|
||||
OpenID-Connect is a protocol (based on OAuth 2.0) that allows services
|
||||
to identify users who have authenticated with some central service.
|
||||
|
||||
Support for OpenID-Connect in rclone is currently achieved using
|
||||
another software package called
|
||||
[oidc-agent](https://github.com/indigo-dc/oidc-agent). This is a
|
||||
command-line tool that facilitates obtaining an access token. Once
|
||||
installed and configured, an access token is obtained by running the
|
||||
`oidc-token` command. The following example shows a (shortened)
|
||||
access token obtained from the *XDC* OIDC Provider.
|
||||
|
||||
```text
|
||||
paul@celebrimbor:~$ oidc-token XDC
|
||||
eyJraWQ[...]QFXDt0
|
||||
paul@celebrimbor:~$
|
||||
```
|
||||
|
||||
**Note** Before the `oidc-token` command will work, the refresh token
|
||||
must be loaded into the oidc agent. This is done with the `oidc-add`
|
||||
command (e.g., `oidc-add XDC`). This is typically done once per login
|
||||
session. Full details on this and how to register oidc-agent with
|
||||
your OIDC Provider are provided in the [oidc-agent
|
||||
documentation](https://indigo-dc.gitbooks.io/oidc-agent/).
|
||||
|
||||
The rclone `bearer_token_command` configuration option is used to
|
||||
fetch the access token from oidc-agent.
|
||||
|
||||
Configure as a normal WebDAV endpoint, using the 'other' vendor,
|
||||
leaving the username and password empty. When prompted, choose to
|
||||
edit the advanced config and enter the command to get a bearer token
|
||||
(e.g., `oidc-agent XDC`).
|
||||
|
||||
The following example config shows a WebDAV endpoint that uses
|
||||
oidc-agent to supply an access token from the *XDC* OIDC Provider.
|
||||
|
||||
```ini
|
||||
[dcache]
|
||||
type = webdav
|
||||
url = https://dcache.example.org/
|
||||
vendor = other
|
||||
bearer_token_command = oidc-token XDC
|
||||
```
|
||||
+30
-1
@@ -1725,6 +1725,32 @@ will only work well for the bucket based backends (eg s3, b2,
|
||||
azureblob, swift) but these are the only backends likely to have
|
||||
millions of entries in a directory.
|
||||
|
||||
### --log-buffer-size SizeSuffix
|
||||
|
||||
Keep this much of the most recent logs in memory so that they can be
|
||||
read with the rc call [core/log](/rc/#core-log) or returned from rc
|
||||
calls with the [_logs](/rc/#logs) parameter. For example
|
||||
`--log-buffer-size 1M`.
|
||||
|
||||
When the log buffer is full the oldest log entries are dropped, and
|
||||
making it smaller keeps the most recent entries. Note that when a
|
||||
client is polling the rc the calls it makes are logged too, so size
|
||||
this for the poll rate as well as the amount of data being
|
||||
transferred, or turn the logging of the calls off with
|
||||
[--rc-log-calls=false](/rc/#--rc-log-calls).
|
||||
|
||||
This is disabled by default (`--log-buffer-size 0`). Enabling it
|
||||
causes every log entry to be formatted as JSON as well as its normal
|
||||
format which will use a little more CPU.
|
||||
|
||||
Note that the log buffer only contains logs at the current
|
||||
`--log-level`.
|
||||
|
||||
**Warning**: anyone who can authenticate to the rc can read the log
|
||||
buffer, including logs made before they connected. If you use `--dump
|
||||
auth` or `--dump bodies` then the log buffer will contain credentials
|
||||
and file contents.
|
||||
|
||||
### --log-file string
|
||||
|
||||
Log all of rclone's output to a file. This is not active by default.
|
||||
@@ -1878,7 +1904,10 @@ here for clarity.
|
||||
|
||||
Completed data transfer logs will have extra `size` information. Logs
|
||||
which are about a particular object will have `object` and
|
||||
`objectType` fields also.
|
||||
`objectType` fields also. Logs which rclone can attribute to the rc job which
|
||||
made them will have `jobid` and `group` fields with the ID of the job
|
||||
and the name of its stats group. Note that the text log format has no
|
||||
equivalent of these fields.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -210,6 +210,24 @@ Expire finished async jobs older than DURATION (default 60s).
|
||||
|
||||
Interval duration to check for expired async jobs (default 10s).
|
||||
|
||||
### --rc-log-calls
|
||||
|
||||
By default rclone logs each rc call and its reply at `DEBUG` level.
|
||||
|
||||
A client which reads the logs over the rc, with
|
||||
[core/events](#core-events) or [core/log](#core-log), sees the logs of
|
||||
its own calls this way, which can drown out everything else. Set this
|
||||
to false to stop rclone logging the calls.
|
||||
|
||||
This can be changed while rclone is running with
|
||||
[options/set](#options-set):
|
||||
|
||||
```console
|
||||
$ rclone rc options/set --json '{"rc": {"LogCalls": false}}'
|
||||
```
|
||||
|
||||
Default On.
|
||||
|
||||
### --rc-no-auth
|
||||
|
||||
By default rclone will require authorisation to have been set up on
|
||||
@@ -562,6 +580,125 @@ $ rclone rc --json '{ "group": "job/1" }' core/stats
|
||||
}
|
||||
```
|
||||
|
||||
### Returning the logs of a call with _logs = true {#logs}
|
||||
|
||||
If `_logs` is set then the logs made while the call was running will
|
||||
be returned along with its result. This needs the log buffer to be
|
||||
enabled with [--log-buffer-size](/docs/#log-buffer-size-sizesuffix)
|
||||
otherwise the call will return an error.
|
||||
|
||||
`_logs` can be set to `true` to return all the logs, or to a log level
|
||||
(e.g. `"INFO"` or `"ERROR"`) to return only the logs at that level or
|
||||
more severe.
|
||||
|
||||
It can also be set to an object to control what is returned:
|
||||
|
||||
- `level` - only return the logs at this level or more severe - optional
|
||||
- `unattributed` - set this to `false` to leave out the logs which
|
||||
aren't attributed to a job - optional, defaults to `true`
|
||||
|
||||
```console
|
||||
$ rclone rc sync/copy srcFs=/tmp/src dstFs=/tmp/dst \
|
||||
--json '{"_logs": {"level": "INFO", "unattributed": false}}'
|
||||
```
|
||||
|
||||
The logs are returned as a `_logs` key in the result of the call, or
|
||||
in the error response if the call failed. It has the following keys:
|
||||
|
||||
- `entries` - array of logs, oldest first, in the same format
|
||||
as returned by [core/log](#core-log).
|
||||
- `next` - the sequence number after the last entry which can be passed
|
||||
as `since` to [core/log](#core-log) to read the logs made after the call.
|
||||
- `lost` - the number of log entries which were made while the call was
|
||||
running but had been dropped from the log buffer as it was too small.
|
||||
|
||||
```console
|
||||
$ rclone rc sync/copy srcFs=/tmp/src dstFs=/tmp/dst _logs=true
|
||||
{
|
||||
"_logs": {
|
||||
"entries": [
|
||||
{
|
||||
"group": "job/1",
|
||||
"jobid": 1,
|
||||
"level": "info",
|
||||
"msg": "Copied (new)",
|
||||
"object": "file.txt",
|
||||
"objectType": "*local.Object",
|
||||
"seq": 2,
|
||||
"size": 3,
|
||||
"source": "operations/copy.go:385",
|
||||
"time": "2026-09-17T12:43:42.736052334+01:00"
|
||||
}
|
||||
],
|
||||
"lost": 0,
|
||||
"next": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the call was started with `_async` then the logs made so far are
|
||||
returned as `_logs` in the `output` of [job/status](#job-status), so
|
||||
the `output` is the same as would have been returned if the call was
|
||||
made synchronously. This returns at most 1000 entries by default -
|
||||
pass the `next` value returned as `since` to `job/status` to read
|
||||
more, or `limit` to change the number of entries returned.
|
||||
|
||||
Note that:
|
||||
|
||||
- Where rclone knows which rc job made a log entry it records the job's
|
||||
ID as `jobid` and its stats group as `group` in the entry. This is
|
||||
the case for the logs made by the sync, copy, move, check and the
|
||||
other operations, such as the per file `Copied (new)` logs, and for
|
||||
the directory listings they do.
|
||||
- Logs from the backends, from the filters, from the retries made by
|
||||
the rate limiter, and from `sync/bisync` are **not** attributed
|
||||
yet. Neither are the logs of anything which isn't an rc job, such as
|
||||
a mount.
|
||||
- `_logs` returns the entries for the job and the unattributed entries
|
||||
made **while** the job was running. The unattributed entries may have
|
||||
come from other jobs, or other rclone activity, running at the same
|
||||
time - set `unattributed` to `false` to leave them out. Entries
|
||||
attributed to other jobs are always left out.
|
||||
- The log buffer only contains logs at the current `--log-level`. This
|
||||
is currently a global setting so it can't be changed for a call with
|
||||
`_config`. Use [options/set](#options-set) to change it.
|
||||
- `_logs` can't be used on an rc server without authentication unless
|
||||
`--rc-no-auth` is in use.
|
||||
|
||||
### Following the logs with core/events {#events}
|
||||
|
||||
[core/events](#core-events) streams the logs over a single HTTP
|
||||
connection as they happen, which avoids polling
|
||||
[core/log](#core-log). It doesn't need `--log-buffer-size` to be set
|
||||
as nothing is stored - the logs are sent as they are made.
|
||||
|
||||
The response is newline delimited JSON - one object per line, each
|
||||
with a `type`:
|
||||
|
||||
```console
|
||||
$ curl -u user:pass -N -X POST http://127.0.0.1:5572/core/events
|
||||
{"type":"start","seq":42}
|
||||
{"type":"log","seq":42,"time":"2026-09-21T12:16:41.134507+01:00","level":"info","msg":"Copied (new)","object":"file.txt","objectType":"*local.Object","size":3,"source":"operations/copy.go:385","jobid":1,"group":"job/1"}
|
||||
{"type":"heartbeat","time":"2026-09-21T12:17:11.134507+01:00"}
|
||||
```
|
||||
|
||||
The `log` events are in the same format as the entries returned by
|
||||
[core/log](#core-log), so they carry `jobid` and `group` where rclone
|
||||
can attribute them - see [_logs](#logs) for what can be attributed.
|
||||
|
||||
The stream can be filtered with `level`, `group`, `jobid` and
|
||||
`unattributed` as `core/log` is.
|
||||
|
||||
If the client doesn't read the stream fast enough then events are
|
||||
dropped and a `dropped` event says how many. If `--log-buffer-size` is
|
||||
set then passing `since` with the `seq` of the last entry seen sends
|
||||
the entries from the buffer first, so a client which reconnects
|
||||
doesn't miss anything.
|
||||
|
||||
Note that every rc call is logged at `DEBUG` level, so a client
|
||||
streaming at that level sees the logs of its own polling. Use
|
||||
[--rc-log-calls=false](#--rc-log-calls) to turn that off.
|
||||
|
||||
## Data types {#data-types}
|
||||
|
||||
When the API returns types, these will mostly be straight forward
|
||||
|
||||
@@ -548,14 +548,12 @@ func (s *StatsInfo) Transferred() []TransferSnapshot {
|
||||
}
|
||||
|
||||
// Log outputs the StatsInfo to the log
|
||||
//
|
||||
// The stats are attached as a hidden structured field so they appear
|
||||
// in JSON log output but not in the text.
|
||||
func (s *StatsInfo) Log() {
|
||||
if s.ci.UseJSONLog {
|
||||
out, _ := s.RemoteStats(false)
|
||||
fs.LogLevelPrintf(s.ci.StatsLogLevel, nil, "%v%v\n", s, fs.LogValueHide("stats", out))
|
||||
} else {
|
||||
fs.LogLevelPrintf(s.ci.StatsLogLevel, nil, "%v\n", s)
|
||||
}
|
||||
|
||||
out, _ := s.RemoteStats(false)
|
||||
fs.LogLevelPrintf(s.ci.StatsLogLevel, nil, "%v%v\n", s, fs.LogValueHide("stats", out))
|
||||
}
|
||||
|
||||
// Bytes updates the stats for bytes bytes
|
||||
|
||||
@@ -17,6 +17,9 @@ var groups *statsGroups
|
||||
func init() {
|
||||
// Init stats container
|
||||
groups = newStatsGroups()
|
||||
|
||||
// Attribute logs to stats groups
|
||||
fs.StatsGroupFromContext = StatsGroupFromContext
|
||||
}
|
||||
|
||||
func rcListStats(ctx context.Context, in rc.Params) (rc.Params, error) {
|
||||
|
||||
@@ -310,3 +310,13 @@ func TestNewStatsGroupDoesNotStartAverageLoop(t *testing.T) {
|
||||
assert.False(t, startedAfterDone,
|
||||
"averageLoop should stop when the last transfer is done")
|
||||
}
|
||||
|
||||
// Check fs can read the stats group to attribute logs to it
|
||||
func TestFsStatsGroupFromContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, ok := fs.StatsGroupFromContext(ctx)
|
||||
assert.False(t, ok)
|
||||
group, ok := fs.StatsGroupFromContext(WithStatsGroup(ctx, "job/1"))
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "job/1", group)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package accounting
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -512,3 +515,28 @@ func TestRemoveDoneTransfers(t *testing.T) {
|
||||
assert.Equal(t, transfers, len(s.startedTransfers))
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Check the log has the stats attached as a structured field for
|
||||
// JSON output regardless of --use-json-log but that they don't
|
||||
// appear in the text.
|
||||
func TestStatsLog(t *testing.T) {
|
||||
ctx, ci := fs.AddConfig(context.Background())
|
||||
ci.StatsLogLevel = fs.LogLevelNotice
|
||||
for _, useJSONLog := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("UseJSONLog=%v", useJSONLog), func(t *testing.T) {
|
||||
ci.UseJSONLog = useJSONLog
|
||||
var buf bytes.Buffer
|
||||
fs.SetLogger(slog.NewJSONHandler(&buf, nil))
|
||||
defer fs.SetLogger(slog.NewTextHandler(io.Discard, nil))
|
||||
s := NewStats(ctx)
|
||||
s.Log()
|
||||
var out struct {
|
||||
Msg string `json:"msg"`
|
||||
Stats map[string]any `json:"stats"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &out), buf.String())
|
||||
assert.Equal(t, s.String()+"\n", out.Msg)
|
||||
assert.Contains(t, out.Stats, "totalTransfers")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,30 @@ type tokenBucket struct {
|
||||
curr buckets
|
||||
prev buckets
|
||||
toggledOff bool
|
||||
started bool
|
||||
currLimit fs.BwTimeSlot
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Apply --bwlimit when the config is reloaded, e.g. by the rc
|
||||
fs.BwLimitReload = TokenBucket.reload
|
||||
}
|
||||
|
||||
// reload applies a changed --bwlimit to the token bucket.
|
||||
//
|
||||
// This does nothing before the token bucket is started as
|
||||
// StartTokenBucket reads the limit itself.
|
||||
func (tb *tokenBucket) reload(ci *fs.ConfigInfo) error {
|
||||
limitNow := ci.BwLimit.LimitAt(time.Now())
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
if !tb.started || tb.currLimit.Bandwidth == limitNow.Bandwidth {
|
||||
return nil
|
||||
}
|
||||
tb._setBwLimit(limitNow.Bandwidth)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return true if limit is disabled
|
||||
//
|
||||
// Call with lock held
|
||||
@@ -111,6 +132,7 @@ func (tb *tokenBucket) StartTokenBucket(ctx context.Context) {
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
ci := fs.GetConfig(ctx)
|
||||
tb.started = true
|
||||
tb.currLimit = ci.BwLimit.LimitAt(time.Now())
|
||||
if tb.currLimit.Bandwidth.IsSet() {
|
||||
tb.curr = newTokenBucket(tb.currLimit.Bandwidth)
|
||||
@@ -190,13 +212,29 @@ func (tb *tokenBucket) LimitBandwidth(i TokenBucketSlot, n int) {
|
||||
func (tb *tokenBucket) SetBwLimit(bandwidth fs.BwPair) {
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
tb._setBwLimit(bandwidth)
|
||||
}
|
||||
|
||||
// Set the current bandwidth limit
|
||||
//
|
||||
// Call with lock held
|
||||
func (tb *tokenBucket) _setBwLimit(bandwidth fs.BwPair) {
|
||||
// If the limits are toggled off then the change should only
|
||||
// become active on the next toggle, which swaps curr and prev
|
||||
targetBucket := &tb.curr
|
||||
if tb.toggledOff {
|
||||
targetBucket = &tb.prev
|
||||
}
|
||||
if bandwidth.IsSet() {
|
||||
tb.curr = newTokenBucket(bandwidth)
|
||||
*targetBucket = newTokenBucket(bandwidth)
|
||||
fs.Logf(nil, "Bandwidth limit set to %v", bandwidth)
|
||||
} else {
|
||||
tb.curr._setOff()
|
||||
targetBucket._setOff()
|
||||
fs.Logf(nil, "Bandwidth limit reset to unlimited")
|
||||
}
|
||||
// Record the limit so the SIGUSR2 toggle knows there is one
|
||||
// and a scheduled change knows what it is replacing
|
||||
tb.currLimit.Bandwidth = bandwidth
|
||||
}
|
||||
|
||||
// read and set the bandwidth limits
|
||||
|
||||
@@ -114,3 +114,76 @@ func TestRcBwLimit(t *testing.T) {
|
||||
}, out)
|
||||
|
||||
}
|
||||
|
||||
// Check options/set applies a changed --bwlimit to the token bucket
|
||||
func TestBwLimitOptionsSet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ci := fs.GetConfig(ctx)
|
||||
oldBwLimit, oldStarted, oldCurr, oldCurrLimit := ci.BwLimit, TokenBucket.started, TokenBucket.curr, TokenBucket.currLimit
|
||||
defer func() {
|
||||
ci.BwLimit = oldBwLimit
|
||||
TokenBucket.started, TokenBucket.curr, TokenBucket.currLimit = oldStarted, oldCurr, oldCurrLimit
|
||||
}()
|
||||
|
||||
setBwLimit := func(rate string) {
|
||||
t.Helper()
|
||||
call := rc.Calls.Get("options/set")
|
||||
require.NotNil(t, call)
|
||||
_, err := call.Fn(ctx, rc.Params{"main": rc.Params{"BwLimit": rate}})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Nothing happens before the token bucket is started
|
||||
TokenBucket.started = false
|
||||
TokenBucket.curr = buckets{}
|
||||
setBwLimit("100k")
|
||||
assert.Nil(t, TokenBucket.curr[TokenBucketSlotAccounting])
|
||||
|
||||
// Once started the limit is applied
|
||||
TokenBucket.started = true
|
||||
TokenBucket.currLimit = fs.BwTimeSlot{}
|
||||
setBwLimit("200k")
|
||||
require.NotNil(t, TokenBucket.curr[TokenBucketSlotTransportTx])
|
||||
assert.Equal(t, rate.Limit(200*1024), TokenBucket.curr[TokenBucketSlotTransportTx].Limit())
|
||||
|
||||
// and changed again
|
||||
setBwLimit("300k")
|
||||
assert.Equal(t, rate.Limit(300*1024), TokenBucket.curr[TokenBucketSlotTransportTx].Limit())
|
||||
|
||||
// and turned off
|
||||
setBwLimit("off")
|
||||
assert.Nil(t, TokenBucket.curr[TokenBucketSlotTransportTx])
|
||||
}
|
||||
|
||||
// Check setting the limit records it and respects the SIGUSR2 toggle
|
||||
func TestSetBwLimitToggledOff(t *testing.T) {
|
||||
oldCurr, oldPrev, oldToggledOff, oldCurrLimit := TokenBucket.curr, TokenBucket.prev, TokenBucket.toggledOff, TokenBucket.currLimit
|
||||
defer func() {
|
||||
TokenBucket.curr, TokenBucket.prev = oldCurr, oldPrev
|
||||
TokenBucket.toggledOff, TokenBucket.currLimit = oldToggledOff, oldCurrLimit
|
||||
}()
|
||||
TokenBucket.curr, TokenBucket.prev = buckets{}, buckets{}
|
||||
TokenBucket.toggledOff, TokenBucket.currLimit = false, fs.BwTimeSlot{}
|
||||
|
||||
// Normally the limit is set straight away
|
||||
TokenBucket.SetBwLimit(fs.BwPair{Tx: 1024, Rx: 1024})
|
||||
require.NotNil(t, TokenBucket.curr[TokenBucketSlotTransportTx])
|
||||
assert.Equal(t, rate.Limit(1024), TokenBucket.curr[TokenBucketSlotTransportTx].Limit())
|
||||
|
||||
// The limit is recorded, otherwise SIGUSR2 ignores it
|
||||
assert.True(t, TokenBucket.currLimit.Bandwidth.IsSet())
|
||||
|
||||
// Toggled off with SIGUSR2 - curr is off and prev holds the limits
|
||||
TokenBucket.toggledOff = true
|
||||
TokenBucket.curr, TokenBucket.prev = TokenBucket.prev, TokenBucket.curr
|
||||
|
||||
// Setting a limit now must not turn the limits back on
|
||||
TokenBucket.SetBwLimit(fs.BwPair{Tx: 2048, Rx: 2048})
|
||||
assert.Nil(t, TokenBucket.curr[TokenBucketSlotTransportTx], "limits toggled off must stay off")
|
||||
|
||||
// It becomes active on the next toggle
|
||||
TokenBucket.toggledOff = false
|
||||
TokenBucket.curr, TokenBucket.prev = TokenBucket.prev, TokenBucket.curr
|
||||
require.NotNil(t, TokenBucket.curr[TokenBucketSlotTransportTx])
|
||||
assert.Equal(t, rate.Limit(2048), TokenBucket.curr[TokenBucketSlotTransportTx].Limit())
|
||||
}
|
||||
@@ -43,6 +43,22 @@ var (
|
||||
// implementation from the fs
|
||||
CountError = func(ctx context.Context, err error) error { return err }
|
||||
|
||||
// StatsGroupFromContext returns the stats group from ctx if set.
|
||||
//
|
||||
// This is used to attribute logs to stats groups.
|
||||
//
|
||||
// This is a function pointer to decouple the accounting
|
||||
// implementation from the fs
|
||||
StatsGroupFromContext = func(ctx context.Context) (string, bool) { return "", false }
|
||||
|
||||
// JobIDFromContext returns the ID of the rc job in ctx if set.
|
||||
//
|
||||
// This is used to attribute logs to the rc job which made them.
|
||||
//
|
||||
// This is a function pointer to decouple the rc jobs
|
||||
// implementation from the fs
|
||||
JobIDFromContext = func(ctx context.Context) (int64, bool) { return 0, false }
|
||||
|
||||
// ConfigProvider is the config key used for provider options
|
||||
ConfigProvider = "provider"
|
||||
|
||||
@@ -697,6 +713,10 @@ func init() {
|
||||
// be there but we can't move due to them being visible here in the rc.
|
||||
var LogReload = func(*ConfigInfo) error { return nil }
|
||||
|
||||
// BwLimitReload is written by fs/accounting to apply --bwlimit when
|
||||
// the config is reloaded, e.g. by the rc options/set.
|
||||
var BwLimitReload = func(*ConfigInfo) error { return nil }
|
||||
|
||||
// Reload assumes the config has been edited and does what is necessary to make it live
|
||||
func (ci *ConfigInfo) Reload(ctx context.Context) error {
|
||||
// Set -vv if --dump is in use
|
||||
@@ -749,6 +769,10 @@ func (ci *ConfigInfo) Reload(ctx context.Context) error {
|
||||
nonZero(&ci.Transfers)
|
||||
nonZero(&ci.Checkers)
|
||||
|
||||
if err := BwLimitReload(ci); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return LogReload(ci)
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -583,7 +583,7 @@ func (f *Filter) IncludeObject(ctx context.Context, o fs.Object) bool {
|
||||
var err error
|
||||
metadata, err = fs.GetMetadata(ctx, o)
|
||||
if err != nil {
|
||||
fs.Errorf(o, "Failed to read metadata: %v", err)
|
||||
fs.ErrorfCtx(ctx, o, "Failed to read metadata: %v", err)
|
||||
metadata = nil
|
||||
}
|
||||
|
||||
@@ -653,7 +653,7 @@ func (f *Filter) MakeListR(ctx context.Context, NewObject func(ctx context.Conte
|
||||
// Count and log the error but carry on so that one
|
||||
// unreadable file (e.g. permission denied) doesn't stop
|
||||
// the other --files-from files being processed.
|
||||
fs.Errorf(remote, "--files-from failed to read file: %v", err)
|
||||
fs.ErrorfCtx(ctx, remote, "--files-from failed to read file: %v", err)
|
||||
_ = fs.CountError(gCtx, err)
|
||||
} else {
|
||||
err = callback(entries)
|
||||
|
||||
+7
-7
@@ -67,7 +67,7 @@ func DirSorted(ctx context.Context, f fs.Fs, includeAll bool, dir string) (entri
|
||||
// called.
|
||||
fi := filter.GetConfig(ctx)
|
||||
if !includeAll && fi.ListContainsExcludeFile(entries) {
|
||||
fs.Debugf(dir, "Excluded")
|
||||
fs.DebugfCtx(ctx, dir, "Excluded")
|
||||
return nil, nil
|
||||
}
|
||||
return filterAndSortDir(ctx, entries, includeAll, dir, fi.IncludeObject, fi.IncludeDirectory(ctx, f))
|
||||
@@ -113,7 +113,7 @@ func DirSortedFn(ctx context.Context, f fs.Fs, includeAll bool, dir string, call
|
||||
// starting directory, otherwise ListDirSorted should not be
|
||||
// called.
|
||||
if !includeAll && fi.ListContainsExcludeFile(entries) {
|
||||
fs.Debugf(dir, "Excluded")
|
||||
fs.DebugfCtx(ctx, dir, "Excluded")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ func filterDir(ctx context.Context, entries fs.DirEntries, includeAll bool, dir
|
||||
// Make sure we don't delete excluded files if not required
|
||||
if !includeAll && !IncludeObject(ctx, x) {
|
||||
ok = false
|
||||
fs.Debugf(x, "Excluded")
|
||||
fs.DebugfCtx(ctx, x, "Excluded")
|
||||
}
|
||||
case fs.Directory:
|
||||
if !includeAll {
|
||||
@@ -160,7 +160,7 @@ func filterDir(ctx context.Context, entries fs.DirEntries, includeAll bool, dir
|
||||
}
|
||||
if !include {
|
||||
ok = false
|
||||
fs.Debugf(x, "Excluded")
|
||||
fs.DebugfCtx(ctx, x, "Excluded")
|
||||
}
|
||||
}
|
||||
default:
|
||||
@@ -173,13 +173,13 @@ func filterDir(ctx context.Context, entries fs.DirEntries, includeAll bool, dir
|
||||
// ignore
|
||||
case !strings.HasPrefix(remote, prefix):
|
||||
ok = false
|
||||
fs.Errorf(entry, "Entry doesn't belong in directory %q (too short) - ignoring", dir)
|
||||
fs.ErrorfCtx(ctx, entry, "Entry doesn't belong in directory %q (too short) - ignoring", dir)
|
||||
case remote == dir:
|
||||
ok = false
|
||||
fs.Errorf(entry, "Entry doesn't belong in directory %q (same as directory) - ignoring", dir)
|
||||
fs.ErrorfCtx(ctx, entry, "Entry doesn't belong in directory %q (same as directory) - ignoring", dir)
|
||||
case strings.ContainsRune(remote[len(prefix):], '/') && !bucket.IsAllSlashes(remote[len(prefix):]):
|
||||
ok = false
|
||||
fs.Errorf(entry, "Entry doesn't belong in directory %q (contains subdir) - ignoring", dir)
|
||||
fs.ErrorfCtx(ctx, entry, "Entry doesn't belong in directory %q (contains subdir) - ignoring", dir)
|
||||
default:
|
||||
// ok
|
||||
}
|
||||
|
||||
+4
-4
@@ -111,7 +111,7 @@ func (ls *Sorter) keyToEntry(ctx context.Context, key string) (entry fs.DirEntry
|
||||
} else {
|
||||
obj, err := ls.f.NewObject(ctx, remote)
|
||||
if err != nil {
|
||||
fs.Errorf(ls.f, "sorter: failed to re-create object %q: %v", remote, err)
|
||||
fs.ErrorfCtx(ctx, ls.f, "sorter: failed to re-create object %q: %v", remote, err)
|
||||
return nil, fmt.Errorf("sorter: failed to re-create object: %w", err)
|
||||
}
|
||||
entry = obj
|
||||
@@ -137,7 +137,7 @@ func (ls *Sorter) sendEntriesToExtSort(entries fs.DirEntries) (err error) {
|
||||
}
|
||||
|
||||
func (ls *Sorter) startExtSort() (err error) {
|
||||
fs.Logf(ls.f, "Switching to on disk sorting as more than %d entries in one directory detected", ls.cutoff)
|
||||
fs.LogfCtx(ls.ctx, ls.f, "Switching to on disk sorting as more than %d entries in one directory detected", ls.cutoff)
|
||||
ls.inputChan = make(chan string, 100)
|
||||
// Options to control the extsort
|
||||
opt := extsort.Config{
|
||||
@@ -171,9 +171,9 @@ func (ls *Sorter) startExtSort() (err error) {
|
||||
ls.extSort = true
|
||||
|
||||
// Send the accumulated entries to the sorter
|
||||
fs.Debugf(ls.f, "Sending accumulated directory entries to disk")
|
||||
fs.DebugfCtx(ls.ctx, ls.f, "Sending accumulated directory entries to disk")
|
||||
err = ls.sendEntriesToExtSort(ls.entries)
|
||||
fs.Debugf(ls.f, "Done sending accumulated directory entries to disk")
|
||||
fs.DebugfCtx(ls.ctx, ls.f, "Done sending accumulated directory entries to disk")
|
||||
clear(ls.entries)
|
||||
ls.entries = nil
|
||||
return err
|
||||
|
||||
@@ -140,11 +140,16 @@ func LogLevelToSlog(level LogLevel) slog.Level {
|
||||
return slogLevel
|
||||
}
|
||||
|
||||
func logSlog(level LogLevel, text string, attrs []any) {
|
||||
logger.Log(context.Background(), LogLevelToSlog(level), text, attrs...)
|
||||
func logSlog(ctx context.Context, level LogLevel, text string, attrs []any) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
// ctx is passed to the log handler which uses it to attribute
|
||||
// the log to the rc job and stats group it was made by.
|
||||
logger.Log(ctx, LogLevelToSlog(level), text, attrs...)
|
||||
}
|
||||
|
||||
func logSlogWithObject(level LogLevel, o any, text string, attrs []any) {
|
||||
func logSlogWithObject(ctx context.Context, level LogLevel, o any, text string, attrs []any) {
|
||||
if o != nil {
|
||||
var object string
|
||||
switch o.(type) {
|
||||
@@ -160,24 +165,46 @@ func logSlogWithObject(level LogLevel, o any, text string, attrs []any) {
|
||||
"objectType", fmt.Sprintf("%T", o),
|
||||
})
|
||||
}
|
||||
logSlog(level, text, attrs)
|
||||
logSlog(ctx, level, text, attrs)
|
||||
}
|
||||
|
||||
// LogPrint produces a log string from the arguments passed in
|
||||
func LogPrint(level LogLevel, o any, text string) {
|
||||
logSlogWithObject(level, o, text, nil)
|
||||
}
|
||||
|
||||
// LogPrintf produces a log string from the arguments passed in
|
||||
func LogPrintf(level LogLevel, o any, text string, args ...any) {
|
||||
text = fmt.Sprintf(text, args...)
|
||||
func logPrintf(ctx context.Context, level LogLevel, o any, text string, args ...any) {
|
||||
// Note that text isn't assigned to here so go vet can see this
|
||||
// is a printf wrapper and check the format strings of the
|
||||
// callers.
|
||||
msg := fmt.Sprintf(text, args...)
|
||||
var fields []any
|
||||
for _, arg := range args {
|
||||
if item, ok := arg.(LogValueItem); ok {
|
||||
fields = append(fields, item.key, item.value)
|
||||
}
|
||||
}
|
||||
logSlogWithObject(level, o, text, fields)
|
||||
logSlogWithObject(ctx, level, o, msg, fields)
|
||||
}
|
||||
|
||||
func logLevelPrintf(ctx context.Context, level LogLevel, o any, text string, args ...any) {
|
||||
// This uses the global log level, not the one in ctx, as the
|
||||
// log handler filters on the global log level.
|
||||
if GetConfig(context.TODO()).LogLevel >= level {
|
||||
logPrintf(ctx, level, o, text, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogPrint produces a log string from the arguments passed in
|
||||
func LogPrint(level LogLevel, o any, text string) {
|
||||
logSlogWithObject(context.Background(), level, o, text, nil)
|
||||
}
|
||||
|
||||
// LogPrintf produces a log string from the arguments passed in
|
||||
func LogPrintf(level LogLevel, o any, text string, args ...any) {
|
||||
logPrintf(context.Background(), level, o, text, args...)
|
||||
}
|
||||
|
||||
// LogPrintfCtx produces a log string from the arguments passed in.
|
||||
//
|
||||
// The log is attributed to the stats group in ctx, if any.
|
||||
func LogPrintfCtx(ctx context.Context, level LogLevel, o any, text string, args ...any) {
|
||||
logPrintf(ctx, level, o, text, args...)
|
||||
}
|
||||
|
||||
// LogLevelPrint writes logs at the given level
|
||||
@@ -189,9 +216,7 @@ func LogLevelPrint(level LogLevel, o any, text string) {
|
||||
|
||||
// LogLevelPrintf writes logs at the given level
|
||||
func LogLevelPrintf(level LogLevel, o any, text string, args ...any) {
|
||||
if GetConfig(context.TODO()).LogLevel >= level {
|
||||
LogPrintf(level, o, text, args...)
|
||||
}
|
||||
logLevelPrintf(context.Background(), level, o, text, args...)
|
||||
}
|
||||
|
||||
// Panic writes alert log output for this Object or Fs and calls panic().
|
||||
@@ -273,6 +298,11 @@ func Errorf(o any, text string, args ...any) {
|
||||
LogLevelPrintf(LogLevelError, o, text, args...)
|
||||
}
|
||||
|
||||
// ErrorfCtx is Errorf with the log attributed to the stats group in ctx, if any.
|
||||
func ErrorfCtx(ctx context.Context, o any, text string, args ...any) {
|
||||
logLevelPrintf(ctx, LogLevelError, o, text, args...)
|
||||
}
|
||||
|
||||
// Print writes log output for this Object or Fs, same as Logf.
|
||||
func Print(o any, text string) {
|
||||
LogLevelPrint(LogLevelNotice, o, text)
|
||||
@@ -301,6 +331,11 @@ func Logf(o any, text string, args ...any) {
|
||||
LogLevelPrintf(LogLevelNotice, o, text, args...)
|
||||
}
|
||||
|
||||
// LogfCtx is Logf with the log attributed to the stats group in ctx, if any.
|
||||
func LogfCtx(ctx context.Context, o any, text string, args ...any) {
|
||||
logLevelPrintf(ctx, LogLevelNotice, o, text, args...)
|
||||
}
|
||||
|
||||
// Infoc writes info on transfers for this Object or Fs. Use this
|
||||
// level for logging transfers, deletions and things which should
|
||||
// appear with the -v flag.
|
||||
@@ -316,6 +351,11 @@ func Infof(o any, text string, args ...any) {
|
||||
LogLevelPrintf(LogLevelInfo, o, text, args...)
|
||||
}
|
||||
|
||||
// InfofCtx is Infof with the log attributed to the stats group in ctx, if any.
|
||||
func InfofCtx(ctx context.Context, o any, text string, args ...any) {
|
||||
logLevelPrintf(ctx, LogLevelInfo, o, text, args...)
|
||||
}
|
||||
|
||||
// Debug writes debugging output for this Object or Fs. Use this for
|
||||
// debug only. The user must have to specify -vv to see this.
|
||||
func Debug(o any, text string) {
|
||||
@@ -328,6 +368,11 @@ func Debugf(o any, text string, args ...any) {
|
||||
LogLevelPrintf(LogLevelDebug, o, text, args...)
|
||||
}
|
||||
|
||||
// DebugfCtx is Debugf with the log attributed to the stats group in ctx, if any.
|
||||
func DebugfCtx(ctx context.Context, o any, text string, args ...any) {
|
||||
logLevelPrintf(ctx, LogLevelDebug, o, text, args...)
|
||||
}
|
||||
|
||||
// LogDirName returns an object for the logger, logging a root
|
||||
// directory which would normally be "" as the Fs
|
||||
func LogDirName(f Fs, dir string) any {
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/rc"
|
||||
)
|
||||
|
||||
// ErrBufferDisabled is returned when the recent logs are needed but
|
||||
// --log-buffer-size has not been set.
|
||||
var ErrBufferDisabled = rc.NewErrParamInvalid(errors.New("log buffer not enabled - set --log-buffer-size"))
|
||||
|
||||
// Recent holds the most recent log entries if --log-buffer-size is set.
|
||||
var Recent = &Buffer{}
|
||||
|
||||
// Maximum number of entries Get looks at when a limit is in force.
|
||||
const maxExamine = 100_000
|
||||
|
||||
// bufferEntry is a single log entry in the Buffer.
|
||||
type bufferEntry struct {
|
||||
seq int64
|
||||
level slog.Level
|
||||
attribution Attribution
|
||||
json json.RawMessage
|
||||
}
|
||||
|
||||
// Filter selects which entries Buffer.Get returns.
|
||||
//
|
||||
// Note that the zero value of Level is INFO, so the zero value
|
||||
// returns the entries at INFO or more severe.
|
||||
type Filter struct {
|
||||
Level slog.Level // only return entries at this level or more severe - note the zero value is INFO
|
||||
JobID int64 // if set, only return entries attributed to this rc job
|
||||
Group string // if set, only return entries attributed to this stats group
|
||||
Unattributed bool // if set with JobID or Group, also return entries which aren't attributed
|
||||
}
|
||||
|
||||
// match returns true if an entry at level attributed to attribution
|
||||
// should be returned.
|
||||
func (f *Filter) match(level slog.Level, attribution Attribution) bool {
|
||||
if level < f.Level {
|
||||
return false
|
||||
}
|
||||
if f.JobID == 0 && f.Group == "" {
|
||||
return true
|
||||
}
|
||||
if f.JobID != 0 && attribution.JobID == f.JobID {
|
||||
return true
|
||||
}
|
||||
if f.Group != "" && attribution.Group == f.Group {
|
||||
return true
|
||||
}
|
||||
return f.Unattributed && !attribution.Attributed()
|
||||
}
|
||||
|
||||
// Entries is a list of log entries in JSON format as returned by Buffer.Get.
|
||||
type Entries []json.RawMessage
|
||||
|
||||
// String returns a summary of the entries.
|
||||
//
|
||||
// The entries aren't rendered as they are logged when rc replies
|
||||
// are logged at DEBUG level and that would cause the log to feed
|
||||
// back into itself.
|
||||
func (e Entries) String() string {
|
||||
return fmt.Sprintf("%d log entries", len(e))
|
||||
}
|
||||
|
||||
// Buffer is a size limited in memory store of the most recent log
|
||||
// entries in JSON format.
|
||||
//
|
||||
// Each entry is given a sequence number starting from 0 which
|
||||
// increases by one for each entry added. This is included in the
|
||||
// entry as the "seq" field.
|
||||
type Buffer struct {
|
||||
mu sync.Mutex
|
||||
entries []bufferEntry // oldest first in increasing sequence number
|
||||
size int64 // total size of the JSON in entries
|
||||
maxSize int64 // drop old entries to keep size <= this - disabled if <= 0
|
||||
}
|
||||
|
||||
// SetSize sets the maximum size in bytes of the log entries kept.
|
||||
//
|
||||
// Setting this <= 0 disables the Buffer and discards its entries.
|
||||
func (b *Buffer) SetSize(maxSize int64) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.maxSize = maxSize
|
||||
b.trim()
|
||||
}
|
||||
|
||||
// Enabled returns whether the Buffer is storing log entries.
|
||||
func (b *Buffer) Enabled() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.maxSize > 0
|
||||
}
|
||||
|
||||
// Seq returns the sequence number the next log entry will be given.
|
||||
func (b *Buffer) Seq() int64 {
|
||||
return nextSeq.Load()
|
||||
}
|
||||
|
||||
// trim drops the oldest entries until the Buffer is within its size.
|
||||
//
|
||||
// Call with the mutex held.
|
||||
func (b *Buffer) trim() {
|
||||
for len(b.entries) > 0 && b.size > b.maxSize {
|
||||
b.size -= int64(len(b.entries[0].json))
|
||||
b.entries[0] = bufferEntry{} // release the memory
|
||||
b.entries = b.entries[1:]
|
||||
}
|
||||
if len(b.entries) == 0 {
|
||||
b.entries = nil
|
||||
}
|
||||
}
|
||||
|
||||
// add adds a log entry made by makeEntry to the Buffer.
|
||||
//
|
||||
// This is called with the log handler's mutex held so it must not log.
|
||||
func (b *Buffer) add(seq int64, level slog.Level, attribution Attribution, entry json.RawMessage) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.maxSize <= 0 {
|
||||
return
|
||||
}
|
||||
// Don't store an entry bigger than the buffer as it would evict
|
||||
// everything else - it will show up as lost
|
||||
if int64(len(entry)) > b.maxSize {
|
||||
return
|
||||
}
|
||||
b.entries = append(b.entries, bufferEntry{seq: seq, level: level, attribution: attribution, json: entry})
|
||||
b.size += int64(len(entry))
|
||||
b.trim()
|
||||
}
|
||||
|
||||
// Get returns the entries with sequence numbers from <= seq < to
|
||||
// which match filter, oldest first.
|
||||
//
|
||||
// If limit > 0 then at most limit entries are returned and at most
|
||||
// maxExamine entries are looked at, so Get may return early with next
|
||||
// short of to.
|
||||
//
|
||||
// It returns next which is the sequence number to pass as from to
|
||||
// carry on reading where this call finished, and lost which is the
|
||||
// number of entries in the range which are no longer in the Buffer,
|
||||
// either because they were dropped to make space or because they
|
||||
// were too big to store.
|
||||
func (b *Buffer) Get(from, to int64, filter Filter, limit int) (entries Entries, next, lost int64) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
// Sequence numbers are given out for every log entry, whether
|
||||
// it is stored or not, so use that as the end of the log
|
||||
end := nextSeq.Load()
|
||||
to = min(to, end)
|
||||
from = min(max(from, 0), end)
|
||||
entries = Entries{}
|
||||
next = max(to, from)
|
||||
// Find the first entry with seq >= from
|
||||
i := sort.Search(len(b.entries), func(i int) bool {
|
||||
return b.entries[i].seq >= from
|
||||
})
|
||||
examined := 0
|
||||
for ; i < len(b.entries) && b.entries[i].seq < to; i++ {
|
||||
entry := &b.entries[i]
|
||||
// Stop when we have enough entries, or have looked at
|
||||
// enough of them, so this doesn't hold the lock for
|
||||
// too long when the filter matches very little.
|
||||
if limit > 0 && (len(entries) >= limit || examined >= maxExamine) {
|
||||
next = entry.seq
|
||||
break
|
||||
}
|
||||
examined++
|
||||
if filter.match(entry.level, entry.attribution) {
|
||||
entries = append(entries, entry.json)
|
||||
}
|
||||
}
|
||||
lost = max(next-from, 0) - int64(examined)
|
||||
return entries, next, lost
|
||||
}
|
||||
|
||||
// setBufferSize sets the size of Recent from the options.
|
||||
func setBufferSize() {
|
||||
Recent.SetSize(int64(Opt.BufferSize))
|
||||
updateOutput()
|
||||
}
|
||||
|
||||
// nextSeq is the sequence number the next log entry will be given.
|
||||
var nextSeq atomic.Int64
|
||||
|
||||
// makeEntry makes the JSON for a log entry from the JSON formatted
|
||||
// log text, adding the sequence number to it.
|
||||
func makeEntry(seq int64, text string) json.RawMessage {
|
||||
text = strings.TrimSpace(text)
|
||||
if !(strings.HasPrefix(text, `{"`) && strings.HasSuffix(text, "}")) {
|
||||
// This shouldn't happen but make sure we always make valid JSON
|
||||
msg, _ := json.Marshal(text)
|
||||
text = `{"msg":` + string(msg) + `}`
|
||||
}
|
||||
// Build the entry with the sequence number added in a single allocation
|
||||
const prefix = `{"seq":`
|
||||
entry := make(json.RawMessage, 0, len(prefix)+20+len(text))
|
||||
entry = append(entry, prefix...)
|
||||
entry = strconv.AppendInt(entry, seq, 10)
|
||||
entry = append(entry, ',')
|
||||
entry = append(entry, text[1:]...)
|
||||
return entry
|
||||
}
|
||||
|
||||
// logOutput is the log output which feeds the log buffer and the
|
||||
// core/events subscribers.
|
||||
//
|
||||
// This is called with the log handler's mutex held so it must not log.
|
||||
func logOutput(r slog.Record, attribution Attribution, text string) {
|
||||
seq := nextSeq.Add(1) - 1
|
||||
entry := makeEntry(seq, text)
|
||||
Recent.add(seq, r.Level, attribution, entry)
|
||||
publish(r.Level, attribution, entry)
|
||||
}
|
||||
|
||||
// updateOutput connects logOutput to the log handler if the log
|
||||
// buffer or any subscriber needs it and disconnects it if not.
|
||||
//
|
||||
// The output is only connected when needed as it causes every log
|
||||
// entry to be formatted as JSON.
|
||||
func updateOutput() {
|
||||
outputMu.Lock()
|
||||
defer outputMu.Unlock()
|
||||
needed := Recent.Enabled() || subscribed()
|
||||
if needed && outputRemove == nil {
|
||||
outputRemove = Handler.addRecordOutput(true, logOutput)
|
||||
} else if !needed && outputRemove != nil {
|
||||
outputRemove()
|
||||
outputRemove = nil
|
||||
}
|
||||
}
|
||||
|
||||
// outputRemove disconnects logOutput from the log handler if not nil.
|
||||
var (
|
||||
outputMu sync.Mutex
|
||||
outputRemove func()
|
||||
)
|
||||
|
||||
func init() {
|
||||
rc.Add(rc.Call{
|
||||
Path: "core/log",
|
||||
Fn: rcLog,
|
||||
Title: "Returns the most recent log entries.",
|
||||
Help: strings.ReplaceAll(`
|
||||
This returns the most recent log entries from the in memory log buffer.
|
||||
The log buffer must be enabled with the |--log-buffer-size| flag
|
||||
otherwise this will return an error.
|
||||
|
||||
Note that the log buffer only contains logs which were output at the
|
||||
current |--log-level|.
|
||||
|
||||
Parameters:
|
||||
|
||||
- since - return entries with sequence numbers starting from this - optional
|
||||
- Defaults to 0 which returns the oldest entries first.
|
||||
- If negative, e.g. -N then this starts from the most recent N entries.
|
||||
- It is an error for this to be beyond the end of the log (which will
|
||||
happen if rclone has been restarted).
|
||||
- limit - maximum number of entries to return - optional, defaults to 1000, 0 for no limit
|
||||
- level - only return entries at this level or more severe, e.g. "INFO" - optional
|
||||
- group - only return entries attributed to this stats group, e.g. "job/1" - optional
|
||||
- jobid - only return entries attributed to this rc job, e.g. 1 - optional
|
||||
|
||||
Returns:
|
||||
|
||||
- entries - array of log entries, oldest first.
|
||||
- next - pass this as |since| to carry on reading the log from where this call finished.
|
||||
- lost - the number of entries from |since| onwards which had already been dropped from the log buffer.
|
||||
|
||||
Each entry is an object in the same format as used by |--use-json-log|
|
||||
with an additional |seq| entry which is the sequence number of
|
||||
the log entry. Sequence numbers start from 0 and increase by one for
|
||||
each log entry.
|
||||
|
||||
Entries which rclone can attribute to the rc job which made them, such
|
||||
as the per file logs made by a job doing a sync or copy, have a
|
||||
|jobid| entry with the ID of the job and a |group| entry with the name
|
||||
of its stats group, e.g. "job/1" or the value of |_group| passed to
|
||||
the rc call.
|
||||
|
||||
Not all logs can be attributed - see the [_logs](/rc/#logs) docs for
|
||||
which can.
|
||||
|
||||
Example:
|
||||
|
||||
|||sh
|
||||
rclone rc core/log since=-2
|
||||
|||
|
||||
|
||||
Returns:
|
||||
|
||||
|||json
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"level": "notice",
|
||||
"msg": "Serving remote control on http://127.0.0.1:5572/",
|
||||
"seq": 1,
|
||||
"source": "rcserver/rcserver.go:119",
|
||||
"time": "2026-09-17T12:39:29.875104149+01:00"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"msg": "Copied (new)",
|
||||
"object": "file.txt",
|
||||
"objectType": "*local.Object",
|
||||
"seq": 2,
|
||||
"size": 3,
|
||||
"source": "operations/copy.go:385",
|
||||
"time": "2026-09-17T12:39:29.960848893+01:00"
|
||||
}
|
||||
],
|
||||
"lost": 0,
|
||||
"next": 3
|
||||
}
|
||||
|||
|
||||
|
||||
To follow the log, call this repeatedly passing in the |next| value
|
||||
returned as the |since| parameter. If |lost| is non zero then the log
|
||||
buffer was too small to hold all the entries since the last call and
|
||||
that many entries were missed.
|
||||
|
||||
Sequence numbers restart from 0 when rclone restarts, but not when the
|
||||
log buffer is turned off and on again - the entries made while it was
|
||||
off show up as |lost|.
|
||||
|
||||
Note that this filters strictly, so |core/log jobid=N| returns fewer
|
||||
entries than that job's [_logs](/rc/#logs), which also returns the
|
||||
entries which aren't attributed to any job.
|
||||
`, "|", "`"),
|
||||
})
|
||||
}
|
||||
|
||||
// ParseLevel parses a log level as used in the rc, e.g. "INFO", into a slog.Level.
|
||||
func ParseLevel(s string) (slog.Level, error) {
|
||||
var level fs.LogLevel
|
||||
if err := level.Set(s); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return fs.LogLevelToSlog(level), nil
|
||||
}
|
||||
|
||||
// rcLog returns the most recent log entries.
|
||||
func rcLog(ctx context.Context, in rc.Params) (out rc.Params, err error) {
|
||||
if !Recent.Enabled() {
|
||||
return nil, ErrBufferDisabled
|
||||
}
|
||||
since, err := in.GetInt64("since")
|
||||
if rc.NotErrParamNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
seq := Recent.Seq()
|
||||
if since < 0 {
|
||||
since = max(seq+since, 0)
|
||||
} else if since > seq {
|
||||
return nil, rc.NewErrParamInvalid(fmt.Errorf("since %d is beyond the end of the log %d - has rclone been restarted?", since, seq))
|
||||
}
|
||||
limit, err := in.GetInt64("limit")
|
||||
if rc.IsErrParamNotFound(err) {
|
||||
limit = 1000
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
level := slog.LevelDebug
|
||||
levelString, err := in.GetString("level")
|
||||
if err == nil {
|
||||
level, err = ParseLevel(levelString)
|
||||
if err != nil {
|
||||
return nil, rc.NewErrParamInvalid(err)
|
||||
}
|
||||
} else if rc.NotErrParamNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
group, err := in.GetString("group")
|
||||
if rc.NotErrParamNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
jobID, err := in.GetInt64("jobid")
|
||||
if rc.NotErrParamNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
filter := Filter{Level: level, Group: group, JobID: jobID}
|
||||
entries, next, lost := Recent.Get(since, math.MaxInt64, filter, int(min(limit, math.MaxInt32)))
|
||||
return rc.Params{
|
||||
"entries": entries,
|
||||
"next": next,
|
||||
"lost": lost,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/rc"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testAttributionKey struct{}
|
||||
|
||||
// testAttributionCtx is attributed to rc job 1 in stats group "job/1"
|
||||
var testAttributionCtx = context.WithValue(context.Background(), testAttributionKey{}, Attribution{JobID: 1, Group: "job/1"})
|
||||
|
||||
func init() {
|
||||
// Attribute logs in these tests from the context value above,
|
||||
// in place of the fs/accounting and fs/rc/jobs implementations.
|
||||
fs.StatsGroupFromContext = func(ctx context.Context) (string, bool) {
|
||||
attribution, _ := ctx.Value(testAttributionKey{}).(Attribution)
|
||||
return attribution.Group, attribution.Group != ""
|
||||
}
|
||||
fs.JobIDFromContext = func(ctx context.Context) (int64, bool) {
|
||||
attribution, _ := ctx.Value(testAttributionKey{}).(Attribution)
|
||||
return attribution.JobID, attribution.JobID != 0
|
||||
}
|
||||
}
|
||||
|
||||
// testEntry is a decoded log entry from the Buffer.
|
||||
type testEntry struct {
|
||||
Seq int64 `json:"seq"`
|
||||
Level string `json:"level"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
// decodeEntries decodes entries checking they are all valid JSON.
|
||||
func decodeEntries(t *testing.T, entries Entries) (out []testEntry) {
|
||||
t.Helper()
|
||||
for _, entry := range entries {
|
||||
var e testEntry
|
||||
require.NoError(t, json.Unmarshal(entry, &e), string(entry))
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// addEntry adds a log entry in the same format as the JSON log to b.
|
||||
func addEntry(b *Buffer, level slog.Level, msg string) {
|
||||
add(b, level, Attribution{}, fmt.Sprintf(`{"level":%q,"msg":%q}`+"\n", slogLevelToString(level), msg))
|
||||
}
|
||||
|
||||
// add adds a log entry to b the way the log output does.
|
||||
func add(b *Buffer, level slog.Level, attribution Attribution, text string) {
|
||||
seq := nextSeq.Add(1) - 1
|
||||
b.add(seq, level, attribution, makeEntry(seq, text))
|
||||
}
|
||||
|
||||
// newTestBuffer makes an empty buffer of size with sequence numbers starting from 0.
|
||||
func newTestBuffer(size int64) *Buffer {
|
||||
nextSeq.Store(0)
|
||||
b := &Buffer{}
|
||||
b.SetSize(size)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestBufferDisabled(t *testing.T) {
|
||||
b := newTestBuffer(0)
|
||||
assert.False(t, b.Enabled())
|
||||
addEntry(b, slog.LevelInfo, "dropped")
|
||||
// The entry used up a sequence number but wasn't stored
|
||||
assert.Equal(t, int64(1), b.Seq())
|
||||
entries, next, lost := b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
|
||||
assert.Equal(t, Entries{}, entries)
|
||||
assert.Equal(t, int64(1), next)
|
||||
assert.Equal(t, int64(1), lost)
|
||||
}
|
||||
|
||||
func TestBufferGet(t *testing.T) {
|
||||
b := newTestBuffer(1024)
|
||||
assert.True(t, b.Enabled())
|
||||
for i := range 10 {
|
||||
level := slog.LevelInfo
|
||||
if i%2 == 1 {
|
||||
level = slog.LevelError
|
||||
}
|
||||
addEntry(b, level, fmt.Sprintf("msg%d", i))
|
||||
}
|
||||
assert.Equal(t, int64(10), b.Seq())
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
from, to int64
|
||||
level slog.Level
|
||||
limit int
|
||||
wantSeqs []int64
|
||||
wantNext int64
|
||||
wantLost int64
|
||||
}{
|
||||
{name: "all", from: 0, to: math.MaxInt64, level: slog.LevelDebug, wantSeqs: []int64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, wantNext: 10},
|
||||
{name: "range", from: 3, to: 6, level: slog.LevelDebug, wantSeqs: []int64{3, 4, 5}, wantNext: 6},
|
||||
{name: "level", from: 0, to: math.MaxInt64, level: slog.LevelError, wantSeqs: []int64{1, 3, 5, 7, 9}, wantNext: 10},
|
||||
{name: "limit", from: 2, to: math.MaxInt64, level: slog.LevelDebug, limit: 3, wantSeqs: []int64{2, 3, 4}, wantNext: 5},
|
||||
{name: "limit and level", from: 0, to: math.MaxInt64, level: slog.LevelError, limit: 2, wantSeqs: []int64{1, 3}, wantNext: 4},
|
||||
{name: "negative from", from: -5, to: 2, level: slog.LevelDebug, wantSeqs: []int64{0, 1}, wantNext: 2},
|
||||
{name: "from the end", from: 10, to: math.MaxInt64, level: slog.LevelDebug, wantNext: 10},
|
||||
{name: "from past the end", from: 100, to: math.MaxInt64, level: slog.LevelDebug, wantNext: 10},
|
||||
{name: "to before from", from: 5, to: 2, level: slog.LevelDebug, wantNext: 5},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
entries, next, lost := b.Get(test.from, test.to, Filter{Level: test.level}, test.limit)
|
||||
var gotSeqs []int64
|
||||
for _, e := range decodeEntries(t, entries) {
|
||||
gotSeqs = append(gotSeqs, e.Seq)
|
||||
assert.Equal(t, fmt.Sprintf("msg%d", e.Seq), e.Msg)
|
||||
}
|
||||
assert.Equal(t, test.wantSeqs, gotSeqs)
|
||||
assert.Equal(t, test.wantNext, next)
|
||||
assert.Equal(t, test.wantLost, lost)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferDropsOldEntries(t *testing.T) {
|
||||
b := newTestBuffer(1024)
|
||||
for i := range 100 {
|
||||
addEntry(b, slog.LevelInfo, fmt.Sprintf("message number %d", i))
|
||||
}
|
||||
assert.LessOrEqual(t, b.size, int64(1024))
|
||||
|
||||
entries, next, lost := b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
|
||||
decoded := decodeEntries(t, entries)
|
||||
require.NotEmpty(t, decoded)
|
||||
assert.Greater(t, lost, int64(0))
|
||||
assert.Equal(t, lost, decoded[0].Seq, "lost should be the number of entries dropped")
|
||||
assert.Equal(t, int64(100), next)
|
||||
assert.Equal(t, int64(99), decoded[len(decoded)-1].Seq)
|
||||
assert.Equal(t, int(100-lost), len(decoded))
|
||||
|
||||
// The window is entirely in the dropped entries
|
||||
entries, next, gotLost := b.Get(1, 3, Filter{Level: slog.LevelDebug}, 0)
|
||||
assert.Empty(t, entries)
|
||||
assert.Equal(t, int64(2), gotLost)
|
||||
assert.Equal(t, int64(3), next)
|
||||
|
||||
// An entry bigger than the Buffer uses a sequence number but
|
||||
// isn't stored and doesn't evict the other entries
|
||||
addEntry(b, slog.LevelInfo, string(make([]byte, 2048)))
|
||||
assert.Equal(t, int64(101), b.Seq())
|
||||
entries, next, lost = b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
|
||||
assert.Equal(t, len(decoded), len(entries))
|
||||
assert.Equal(t, int64(101), next)
|
||||
assert.Equal(t, decoded[0].Seq+1, lost, "dropped entries plus the oversized one")
|
||||
entries, next, lost = b.Get(100, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
|
||||
assert.Empty(t, entries)
|
||||
assert.Equal(t, int64(101), next)
|
||||
assert.Equal(t, int64(1), lost)
|
||||
|
||||
// A gap in the middle of the range is counted as lost too
|
||||
addEntry(b, slog.LevelInfo, "after the gap")
|
||||
entries, next, lost = b.Get(99, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
|
||||
assert.Len(t, entries, 2)
|
||||
assert.Equal(t, int64(102), next)
|
||||
assert.Equal(t, int64(1), lost)
|
||||
|
||||
// Limit stops before the gap
|
||||
entries, next, lost = b.Get(99, math.MaxInt64, Filter{Level: slog.LevelDebug}, 1)
|
||||
assert.Len(t, entries, 1)
|
||||
assert.Equal(t, int64(101), next)
|
||||
assert.Equal(t, int64(1), lost)
|
||||
|
||||
// Disabling discards the entries
|
||||
addEntry(b, slog.LevelInfo, "kept")
|
||||
b.SetSize(0)
|
||||
assert.False(t, b.Enabled())
|
||||
entries, _, _ = b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
|
||||
assert.Empty(t, entries)
|
||||
assert.Equal(t, int64(103), b.Seq(), "sequence numbers carry on")
|
||||
}
|
||||
|
||||
// Get shouldn't scan the whole buffer when the filter matches very little
|
||||
func TestBufferGetMaxExamine(t *testing.T) {
|
||||
b := newTestBuffer(int64(16 * fs.Mebi))
|
||||
for i := range maxExamine + 10 {
|
||||
add(b, slog.LevelInfo, Attribution{JobID: 2}, fmt.Sprintf(`{"msg":"msg%d"}`, i))
|
||||
}
|
||||
add(b, slog.LevelInfo, Attribution{JobID: 1}, `{"msg":"wanted"}`)
|
||||
|
||||
// Nothing matches in the first maxExamine entries so Get stops there
|
||||
entries, next, lost := b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug, JobID: 1}, 10)
|
||||
assert.Empty(t, entries)
|
||||
assert.Equal(t, int64(maxExamine), next)
|
||||
assert.Equal(t, int64(0), lost)
|
||||
|
||||
// Carrying on from next finds it
|
||||
entries, next, _ = b.Get(next, math.MaxInt64, Filter{Level: slog.LevelDebug, JobID: 1}, 10)
|
||||
decoded := decodeEntries(t, entries)
|
||||
require.Len(t, decoded, 1)
|
||||
assert.Equal(t, "wanted", decoded[0].Msg)
|
||||
assert.Equal(t, int64(maxExamine+11), next)
|
||||
|
||||
// With no limit everything is examined
|
||||
entries, _, _ = b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug, JobID: 1}, 0)
|
||||
assert.Len(t, entries, 1)
|
||||
}
|
||||
|
||||
func TestBufferInvalidJSON(t *testing.T) {
|
||||
b := newTestBuffer(1024)
|
||||
for _, text := range []string{
|
||||
"not \"JSON\"\n",
|
||||
"control chars \a\x00\xff\U0001F600",
|
||||
"{",
|
||||
"{ }",
|
||||
"{}",
|
||||
} {
|
||||
add(b, slog.LevelInfo, Attribution{}, text)
|
||||
}
|
||||
entries, _, _ := b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
|
||||
decoded := decodeEntries(t, entries)
|
||||
require.Len(t, decoded, 5)
|
||||
assert.Equal(t, `not "JSON"`, decoded[0].Msg)
|
||||
assert.Equal(t, "control chars \a\x00\ufffd\U0001F600", decoded[1].Msg)
|
||||
assert.Equal(t, "{", decoded[2].Msg)
|
||||
assert.Equal(t, "{ }", decoded[3].Msg)
|
||||
assert.Equal(t, "{}", decoded[4].Msg)
|
||||
// Check they can all be marshalled
|
||||
_, err := json.Marshal(entries)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestEntriesString(t *testing.T) {
|
||||
entries := Entries{json.RawMessage(`{"seq":0}`), json.RawMessage(`{"seq":1}`)}
|
||||
assert.Equal(t, "2 log entries", entries.String())
|
||||
// Check that the entries aren't rendered when logging an rc reply
|
||||
assert.Equal(t, "map[_logs:map[entries:2 log entries]]", fmt.Sprintf("%+v", rc.Params{"_logs": rc.Params{"entries": entries}}))
|
||||
// but that they marshal as JSON
|
||||
out, err := json.Marshal(entries)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `[{"seq":0},{"seq":1}]`, string(out))
|
||||
}
|
||||
|
||||
// Check setting the option connects and disconnects the buffer
|
||||
func TestSetBufferSize(t *testing.T) {
|
||||
oldOpt := Opt.BufferSize
|
||||
defer func() {
|
||||
Opt.BufferSize = oldOpt
|
||||
setBufferSize()
|
||||
}()
|
||||
outputs := func() int {
|
||||
Handler.mu.Lock()
|
||||
defer Handler.mu.Unlock()
|
||||
return len(Handler.outputExtra)
|
||||
}
|
||||
Opt.BufferSize = 0
|
||||
setBufferSize()
|
||||
assert.False(t, Recent.Enabled())
|
||||
start := outputs()
|
||||
fs.Errorf(nil, "not buffered")
|
||||
|
||||
Opt.BufferSize = 64 * 1024
|
||||
setBufferSize()
|
||||
assert.True(t, Recent.Enabled())
|
||||
assert.Equal(t, start+1, outputs())
|
||||
seq := Recent.Seq()
|
||||
fs.Errorf(nil, "buffered")
|
||||
entries, _, _ := Recent.Get(seq, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
|
||||
decoded := decodeEntries(t, entries)
|
||||
require.Len(t, decoded, 1)
|
||||
assert.Equal(t, "buffered", decoded[0].Msg)
|
||||
|
||||
// Setting again shouldn't connect again
|
||||
setBufferSize()
|
||||
assert.Equal(t, start+1, outputs())
|
||||
|
||||
Opt.BufferSize = 0
|
||||
setBufferSize()
|
||||
assert.False(t, Recent.Enabled())
|
||||
assert.Equal(t, start, outputs())
|
||||
fs.Errorf(nil, "not buffered")
|
||||
entries, _, _ = Recent.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
|
||||
assert.Empty(t, entries)
|
||||
}
|
||||
|
||||
// Check the Buffer works as an output of the handler.
|
||||
func TestBufferFromHandler(t *testing.T) {
|
||||
b := newTestBuffer(64 * 1024)
|
||||
h := NewOutputHandler(io.Discard, nil, logFormatDate|logFormatTime)
|
||||
h.addRecordOutput(true, func(r slog.Record, attribution Attribution, text string) {
|
||||
seq := nextSeq.Add(1) - 1
|
||||
b.add(seq, r.Level, attribution, makeEntry(seq, text))
|
||||
})
|
||||
|
||||
r := slog.NewRecord(t0, fs.SlogLevelNotice, "hello", 0)
|
||||
r.AddAttrs(slog.String("object", "file.txt"))
|
||||
require.NoError(t, h.Handle(testAttributionCtx, r))
|
||||
require.NoError(t, h.Handle(context.Background(), slog.NewRecord(t0, slog.LevelError, "oops", 0)))
|
||||
|
||||
entries, next, lost := b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
|
||||
assert.Equal(t, int64(2), next)
|
||||
assert.Equal(t, int64(0), lost)
|
||||
decoded := decodeEntries(t, entries)
|
||||
require.Len(t, decoded, 2)
|
||||
assert.Equal(t, testEntry{Seq: 0, Level: "notice", Msg: "hello"}, decoded[0])
|
||||
assert.Equal(t, testEntry{Seq: 1, Level: "error", Msg: "oops"}, decoded[1])
|
||||
|
||||
var full map[string]any
|
||||
require.NoError(t, json.Unmarshal(entries[0], &full))
|
||||
assert.Equal(t, "file.txt", full["object"])
|
||||
assert.Equal(t, "job/1", full["group"])
|
||||
assert.Equal(t, float64(1), full["jobid"])
|
||||
assert.Contains(t, full, "time")
|
||||
assert.Contains(t, full, "source")
|
||||
|
||||
// Check the group was recorded for filtering
|
||||
entries, _, _ = b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug, Group: "job/1"}, 0)
|
||||
decoded = decodeEntries(t, entries)
|
||||
require.Len(t, decoded, 1)
|
||||
assert.Equal(t, "hello", decoded[0].Msg)
|
||||
}
|
||||
|
||||
func TestBufferGetGroup(t *testing.T) {
|
||||
b := newTestBuffer(1024)
|
||||
for i, jobID := range []int64{0, 1, 2, 1, 0, 2} {
|
||||
attribution := Attribution{}
|
||||
if jobID != 0 {
|
||||
attribution = Attribution{JobID: jobID, Group: fmt.Sprintf("job/%d", jobID)}
|
||||
}
|
||||
add(b, slog.LevelInfo, attribution, fmt.Sprintf(`{"msg":"msg%d"}`, i))
|
||||
}
|
||||
add(b, slog.LevelDebug, Attribution{JobID: 1, Group: "job/1"}, `{"msg":"msg6"}`)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
filter Filter
|
||||
want []int64
|
||||
}{
|
||||
{"all", Filter{Level: slog.LevelDebug}, []int64{0, 1, 2, 3, 4, 5, 6}},
|
||||
{"group", Filter{Level: slog.LevelDebug, Group: "job/1"}, []int64{1, 3, 6}},
|
||||
{"group and unattributed", Filter{Level: slog.LevelDebug, Group: "job/1", Unattributed: true}, []int64{0, 1, 3, 4, 6}},
|
||||
{"group and level", Filter{Level: slog.LevelInfo, Group: "job/1"}, []int64{1, 3}},
|
||||
{"unknown group", Filter{Level: slog.LevelDebug, Group: "job/3"}, nil},
|
||||
{"unknown group and unattributed", Filter{Level: slog.LevelDebug, Group: "job/3", Unattributed: true}, []int64{0, 4}},
|
||||
{"job", Filter{Level: slog.LevelDebug, JobID: 1}, []int64{1, 3, 6}},
|
||||
{"job and unattributed", Filter{Level: slog.LevelDebug, JobID: 2, Unattributed: true}, []int64{0, 2, 4, 5}},
|
||||
{"job or group", Filter{Level: slog.LevelDebug, JobID: 2, Group: "job/1"}, []int64{1, 2, 3, 5, 6}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
entries, next, lost := b.Get(0, math.MaxInt64, test.filter, 0)
|
||||
var got []int64
|
||||
for _, e := range decodeEntries(t, entries) {
|
||||
got = append(got, e.Seq)
|
||||
}
|
||||
assert.Equal(t, test.want, got)
|
||||
assert.Equal(t, int64(7), next)
|
||||
assert.Equal(t, int64(0), lost, "filtered entries aren't lost")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferConcurrency(t *testing.T) {
|
||||
b := newTestBuffer(4096)
|
||||
var wg sync.WaitGroup
|
||||
for range 4 {
|
||||
wg.Go(func() {
|
||||
for i := range 500 {
|
||||
addEntry(b, slog.LevelInfo, fmt.Sprintf("msg%d", i))
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Go(func() {
|
||||
var from int64
|
||||
for range 500 {
|
||||
var entries []json.RawMessage
|
||||
entries, from, _ = b.Get(from, math.MaxInt64, Filter{Level: slog.LevelDebug}, 10)
|
||||
_ = decodeEntries(t, entries)
|
||||
_ = b.Seq()
|
||||
}
|
||||
})
|
||||
wg.Wait()
|
||||
assert.Equal(t, int64(2000), b.Seq())
|
||||
}
|
||||
|
||||
func TestRcLog(t *testing.T) {
|
||||
call := rc.Calls.Get("core/log")
|
||||
require.NotNil(t, call)
|
||||
ctx := context.Background()
|
||||
|
||||
oldRecent := Recent
|
||||
defer func() { Recent = oldRecent }()
|
||||
Recent = newTestBuffer(0)
|
||||
|
||||
_, err := call.Fn(ctx, rc.Params{})
|
||||
assert.Equal(t, ErrBufferDisabled, err)
|
||||
assert.True(t, rc.IsErrParamInvalid(err), err)
|
||||
|
||||
Recent = newTestBuffer(64 * 1024)
|
||||
for i := range 10 {
|
||||
level := slog.LevelInfo
|
||||
if i%2 == 1 {
|
||||
level = slog.LevelError
|
||||
}
|
||||
addEntry(Recent, level, fmt.Sprintf("msg%d", i))
|
||||
}
|
||||
|
||||
seqs := func(out rc.Params) (seqs []int64) {
|
||||
for _, e := range decodeEntries(t, out["entries"].(Entries)) {
|
||||
seqs = append(seqs, e.Seq)
|
||||
}
|
||||
return seqs
|
||||
}
|
||||
|
||||
out, err := call.Fn(ctx, rc.Params{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []int64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, seqs(out))
|
||||
assert.Equal(t, int64(10), out["next"])
|
||||
assert.Equal(t, int64(0), out["lost"])
|
||||
|
||||
out, err = call.Fn(ctx, rc.Params{"since": 4, "limit": 2})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []int64{4, 5}, seqs(out))
|
||||
assert.Equal(t, int64(6), out["next"])
|
||||
|
||||
out, err = call.Fn(ctx, rc.Params{"since": -3})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []int64{7, 8, 9}, seqs(out))
|
||||
|
||||
out, err = call.Fn(ctx, rc.Params{"since": -100, "level": "error"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []int64{1, 3, 5, 7, 9}, seqs(out))
|
||||
|
||||
_, err = call.Fn(ctx, rc.Params{"level": "potato"})
|
||||
require.Error(t, err)
|
||||
assert.True(t, rc.IsErrParamInvalid(err), err)
|
||||
|
||||
// since at the end is OK but beyond the end is an error
|
||||
out, err = call.Fn(ctx, rc.Params{"since": 10})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out["entries"])
|
||||
assert.Equal(t, int64(10), out["next"])
|
||||
_, err = call.Fn(ctx, rc.Params{"since": 11})
|
||||
require.Error(t, err)
|
||||
assert.True(t, rc.IsErrParamInvalid(err), err)
|
||||
assert.Contains(t, err.Error(), "beyond the end")
|
||||
|
||||
// Check the output can be serialized
|
||||
out, err = call.Fn(ctx, rc.Params{"since": 9})
|
||||
require.NoError(t, err)
|
||||
var buf []byte
|
||||
buf, err = json.Marshal(out)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"entries":[{"seq":9,"level":"ERROR","msg":"msg9"}],"next":10,"lost":0}`, string(buf))
|
||||
|
||||
// Filter by group
|
||||
addEntry(Recent, slog.LevelInfo, "not in a group")
|
||||
add(Recent, slog.LevelInfo, Attribution{JobID: 1, Group: "job/1"}, `{"msg":"in job/1"}`)
|
||||
out, err = call.Fn(ctx, rc.Params{"since": 10, "group": "job/1"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []int64{11}, seqs(out))
|
||||
assert.Equal(t, int64(12), out["next"])
|
||||
|
||||
// Filter by rc job
|
||||
out, err = call.Fn(ctx, rc.Params{"since": 10, "jobid": 1})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []int64{11}, seqs(out))
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/rc"
|
||||
)
|
||||
|
||||
// Number of frames buffered for each subscriber before they get dropped.
|
||||
const subscriberBacklog = 1024
|
||||
|
||||
// newline ends each frame in the stream
|
||||
var newline = []byte{'\n'}
|
||||
|
||||
// subscriber receives log entries as they are made.
|
||||
type subscriber struct {
|
||||
filter Filter
|
||||
frames chan json.RawMessage
|
||||
mu sync.Mutex
|
||||
dropped int64 // frames dropped because frames was full
|
||||
}
|
||||
|
||||
// subscribers receiving log entries
|
||||
var (
|
||||
subscribersMu sync.Mutex
|
||||
subscribers = map[*subscriber]struct{}{}
|
||||
)
|
||||
|
||||
// subscribed returns whether anything is subscribed to the log.
|
||||
func subscribed() bool {
|
||||
subscribersMu.Lock()
|
||||
defer subscribersMu.Unlock()
|
||||
return len(subscribers) > 0
|
||||
}
|
||||
|
||||
// subscribe returns a subscriber receiving the log entries which
|
||||
// match filter and a function to unsubscribe it again.
|
||||
func subscribe(filter Filter) (*subscriber, func()) {
|
||||
s := &subscriber{
|
||||
filter: filter,
|
||||
frames: make(chan json.RawMessage, subscriberBacklog),
|
||||
}
|
||||
subscribersMu.Lock()
|
||||
subscribers[s] = struct{}{}
|
||||
subscribersMu.Unlock()
|
||||
updateOutput()
|
||||
return s, func() {
|
||||
subscribersMu.Lock()
|
||||
delete(subscribers, s)
|
||||
subscribersMu.Unlock()
|
||||
updateOutput()
|
||||
}
|
||||
}
|
||||
|
||||
// takeDropped returns the number of frames dropped since it was last called.
|
||||
func (s *subscriber) takeDropped() int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
dropped := s.dropped
|
||||
s.dropped = 0
|
||||
return dropped
|
||||
}
|
||||
|
||||
// send queues frame for the subscriber, dropping it if it isn't
|
||||
// keeping up. It never blocks as it is called while logging.
|
||||
//
|
||||
// Any frames dropped earlier are reported first so the client is
|
||||
// told where the gap in the stream is.
|
||||
func (s *subscriber) send(frame json.RawMessage) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.dropped > 0 {
|
||||
select {
|
||||
case s.frames <- droppedFrame(s.dropped):
|
||||
s.dropped = 0
|
||||
default:
|
||||
s.dropped++
|
||||
return
|
||||
}
|
||||
}
|
||||
select {
|
||||
case s.frames <- frame:
|
||||
default:
|
||||
s.dropped++
|
||||
}
|
||||
}
|
||||
|
||||
// logFrame makes the frame sent to the subscribers for a log entry.
|
||||
func logFrame(entry json.RawMessage) json.RawMessage {
|
||||
const prefix = `{"type":"log",`
|
||||
frame := make(json.RawMessage, 0, len(prefix)+len(entry))
|
||||
frame = append(frame, prefix...)
|
||||
frame = append(frame, entry[1:]...)
|
||||
return frame
|
||||
}
|
||||
|
||||
// publish sends a log entry to the subscribers which want it.
|
||||
//
|
||||
// This is called with the log handler's mutex held so it must not log
|
||||
// or block.
|
||||
func publish(level slog.Level, attribution Attribution, entry json.RawMessage) {
|
||||
subscribersMu.Lock()
|
||||
defer subscribersMu.Unlock()
|
||||
var frame json.RawMessage
|
||||
for s := range subscribers {
|
||||
if !s.filter.match(level, attribution) {
|
||||
continue
|
||||
}
|
||||
if frame == nil {
|
||||
frame = logFrame(entry)
|
||||
}
|
||||
s.send(frame)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rc.Add(rc.Call{
|
||||
Path: "core/events",
|
||||
Fn: rcEvents,
|
||||
NeedsResponse: true,
|
||||
WritesResponse: true,
|
||||
Title: "Streams events, such as the logs, as they happen.",
|
||||
Help: strings.ReplaceAll(`
|
||||
This streams events as they happen until the connection is closed. It
|
||||
is only available over HTTP.
|
||||
|
||||
Events are returned as newline delimited JSON objects, one per line,
|
||||
each with a |type| saying what it is. The types are:
|
||||
|
||||
- |start| - sent when the stream starts
|
||||
- seq - the sequence number the next log entry will be given
|
||||
- jobid - the rc job of the stream, which [job/stop](#job-stop) stops
|
||||
- |log| - a log entry in the format used by [core/log](#core-log)
|
||||
- |dropped| - sent if the client isn't reading the stream fast enough
|
||||
- count - the number of events dropped
|
||||
- |heartbeat| - sent when the stream is idle so it doesn't time out
|
||||
|
||||
Unlike [core/log](#core-log) this doesn't need |--log-buffer-size| to
|
||||
be set as the events are not stored - they are sent as they happen.
|
||||
|
||||
Parameters:
|
||||
|
||||
- level - only stream logs at this level or more severe, e.g. "INFO" - optional
|
||||
- group - only stream logs attributed to this stats group, e.g. "job/1" - optional
|
||||
- jobid - only stream logs attributed to this rc job, e.g. 1 - optional
|
||||
- unattributed - if set with group or jobid, also stream the logs which
|
||||
aren't attributed to a job - optional
|
||||
- since - send the entries from this sequence number before streaming
|
||||
the new ones - optional
|
||||
- This needs the log buffer to be enabled with |--log-buffer-size|.
|
||||
- Use it with the |seq| from the last entry seen to carry on where a
|
||||
previous stream left off.
|
||||
- heartbeat - seconds between heartbeats, 0 to disable - optional, defaults to 30
|
||||
|
||||
Example:
|
||||
|
||||
|||sh
|
||||
curl -u user:pass -N -X POST http://127.0.0.1:5572/core/events
|
||||
|||
|
||||
|
||||
Returns
|
||||
|
||||
|||json
|
||||
{"type":"start","seq":42}
|
||||
{"type":"log","seq":42,"time":"2026-09-21T12:16:41.134507+01:00","level":"info","msg":"Copied (new)","object":"file.txt","objectType":"*local.Object","size":3,"source":"operations/copy.go:385","jobid":1,"group":"job/1"}
|
||||
{"type":"heartbeat","time":"2026-09-21T12:17:11.134507+01:00"}
|
||||
|||
|
||||
|
||||
Note that the rc calls made by the client reading the stream are
|
||||
logged at DEBUG level like any other, so a client streaming at that
|
||||
level sees its own polling. Use |--rc-log-calls=false| to turn that
|
||||
off.
|
||||
`, "|", "`"),
|
||||
})
|
||||
}
|
||||
|
||||
// rcEvents streams the events to the HTTP response until the
|
||||
// connection is closed.
|
||||
func rcEvents(ctx context.Context, in rc.Params) (out rc.Params, err error) {
|
||||
w, err := in.GetHTTPResponseWriter()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("response object is required: %w", err)
|
||||
}
|
||||
filter, err := eventsFilter(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
since, err := in.GetInt64("since")
|
||||
if rc.NotErrParamNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
replay := err == nil
|
||||
if replay && !Recent.Enabled() {
|
||||
return nil, ErrBufferDisabled
|
||||
}
|
||||
heartbeat := 30 * time.Second
|
||||
if seconds, err := in.GetInt64("heartbeat"); err == nil {
|
||||
heartbeat = time.Duration(seconds) * time.Second
|
||||
} else if rc.NotErrParamNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Subscribe before reading the buffer so no entry is missed
|
||||
// between the two - the ones sent from the buffer are skipped
|
||||
// from the stream below.
|
||||
s, unsubscribe := subscribe(filter)
|
||||
defer unsubscribe()
|
||||
startSeq := Recent.Seq()
|
||||
|
||||
// The write deadline is cleared as this runs until the client
|
||||
// goes away, then set for each write so a stuck client doesn't
|
||||
// block forever.
|
||||
rcw := http.NewResponseController(w)
|
||||
if err := rcw.SetWriteDeadline(time.Time{}); err != nil {
|
||||
return nil, fmt.Errorf("can't stream events: %w", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// The frames are shared between the subscribers so they must
|
||||
// not be written to - the newline goes in a separate write.
|
||||
write := func(frame json.RawMessage) error {
|
||||
if err := rcw.SetWriteDeadline(time.Now().Add(time.Minute)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(frame); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(newline); err != nil {
|
||||
return err
|
||||
}
|
||||
return rcw.Flush()
|
||||
}
|
||||
|
||||
start := `{"type":"start","seq":` + strconv.FormatInt(startSeq, 10)
|
||||
if jobID, ok := fs.JobIDFromContext(ctx); ok {
|
||||
start += `,"jobid":` + strconv.FormatInt(jobID, 10)
|
||||
}
|
||||
if err := write(json.RawMessage(start + `}`)); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if replay {
|
||||
entries, _, lost := Recent.Get(since, startSeq, filter, 0)
|
||||
if lost > 0 {
|
||||
if err := write(droppedFrame(lost)); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := write(logFrame(entry)); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ticker *time.Ticker
|
||||
tick := make(<-chan time.Time)
|
||||
if heartbeat > 0 {
|
||||
ticker = time.NewTicker(heartbeat)
|
||||
defer ticker.Stop()
|
||||
tick = ticker.C
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil
|
||||
case frame := <-s.frames:
|
||||
// Skip the entries already sent from the buffer
|
||||
if replay && frameSeq(frame) < startSeq {
|
||||
continue
|
||||
}
|
||||
if err := write(frame); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
case now := <-tick:
|
||||
// Report any drops which the log going quiet left
|
||||
// unreported
|
||||
if dropped := s.takeDropped(); dropped > 0 {
|
||||
if err := write(droppedFrame(dropped)); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
frame := `{"type":"heartbeat","time":` + strconv.Quote(now.Format(time.RFC3339Nano)) + `}`
|
||||
if err := write(json.RawMessage(frame)); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// droppedFrame makes a frame saying count events were dropped.
|
||||
func droppedFrame(count int64) json.RawMessage {
|
||||
return json.RawMessage(`{"type":"dropped","count":` + strconv.FormatInt(count, 10) + `}`)
|
||||
}
|
||||
|
||||
// frameSeq returns the sequence number of a log frame or -1 if it doesn't have one.
|
||||
func frameSeq(frame json.RawMessage) int64 {
|
||||
var entry struct {
|
||||
Seq *int64 `json:"seq"`
|
||||
}
|
||||
if err := json.Unmarshal(frame, &entry); err != nil || entry.Seq == nil {
|
||||
return -1
|
||||
}
|
||||
return *entry.Seq
|
||||
}
|
||||
|
||||
// eventsFilter reads the log filter from the parameters.
|
||||
func eventsFilter(in rc.Params) (filter Filter, err error) {
|
||||
filter.Level = slog.LevelDebug
|
||||
levelString, err := in.GetString("level")
|
||||
if err == nil {
|
||||
filter.Level, err = ParseLevel(levelString)
|
||||
if err != nil {
|
||||
return filter, rc.NewErrParamInvalid(err)
|
||||
}
|
||||
} else if rc.NotErrParamNotFound(err) {
|
||||
return filter, err
|
||||
}
|
||||
filter.Group, err = in.GetString("group")
|
||||
if rc.NotErrParamNotFound(err) {
|
||||
return filter, err
|
||||
}
|
||||
filter.JobID, err = in.GetInt64("jobid")
|
||||
if rc.NotErrParamNotFound(err) {
|
||||
return filter, err
|
||||
}
|
||||
filter.Unattributed, err = in.GetBool("unattributed")
|
||||
if rc.NotErrParamNotFound(err) {
|
||||
return filter, err
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/rc"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// frame is a decoded event from the stream.
|
||||
type frame struct {
|
||||
Type string `json:"type"`
|
||||
Seq int64 `json:"seq"`
|
||||
Msg string `json:"msg"`
|
||||
JobID int64 `json:"jobid"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// eventServer serves core/events and returns a function to read the
|
||||
// frames from it one at a time and a function to stop it.
|
||||
func eventServer(t *testing.T, in rc.Params) (next func() frame, stop func()) {
|
||||
call := rc.Calls.Get("core/events")
|
||||
require.NotNil(t, call)
|
||||
assert.True(t, call.NeedsResponse)
|
||||
assert.True(t, call.WritesResponse)
|
||||
|
||||
done := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
params := rc.Params{"_response": w}
|
||||
for k, v := range in {
|
||||
params[k] = v
|
||||
}
|
||||
_, err := call.Fn(r.Context(), params)
|
||||
assert.NoError(t, err)
|
||||
close(done)
|
||||
}))
|
||||
|
||||
resp, err := http.Get(server.URL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "application/x-ndjson", resp.Header.Get("Content-Type"))
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
|
||||
next = func() frame {
|
||||
t.Helper()
|
||||
require.True(t, scanner.Scan(), "no more frames: %v", scanner.Err())
|
||||
var f frame
|
||||
require.NoError(t, json.Unmarshal(scanner.Bytes(), &f), scanner.Text())
|
||||
return f
|
||||
}
|
||||
stop = func() {
|
||||
_ = resp.Body.Close()
|
||||
server.CloseClientConnections()
|
||||
server.Close()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Error("core/events didn't return after the client went away")
|
||||
}
|
||||
}
|
||||
return next, stop
|
||||
}
|
||||
|
||||
// logAt logs msg via the real log handler so it goes through the
|
||||
// output to the subscribers.
|
||||
func logAt(ctx context.Context, level fs.LogLevel, msg string) {
|
||||
fs.LogPrintfCtx(ctx, level, nil, "%s", msg)
|
||||
}
|
||||
|
||||
func TestRcEvents(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setTestLogLevel(t, fs.LogLevelInfo)
|
||||
|
||||
next, stop := eventServer(t, rc.Params{"heartbeat": 0})
|
||||
defer stop()
|
||||
|
||||
start := next()
|
||||
assert.Equal(t, "start", start.Type)
|
||||
|
||||
logAt(ctx, fs.LogLevelNotice, "first")
|
||||
f := next()
|
||||
assert.Equal(t, "log", f.Type)
|
||||
assert.Equal(t, "first", f.Msg)
|
||||
assert.Equal(t, start.Seq, f.Seq, "the stream starts at the sequence number in the start frame")
|
||||
|
||||
logAt(testAttributionCtx, fs.LogLevelNotice, "attributed")
|
||||
f = next()
|
||||
assert.Equal(t, "attributed", f.Msg)
|
||||
assert.Equal(t, int64(1), f.JobID)
|
||||
|
||||
// Logs below the requested level aren't sent
|
||||
logAt(ctx, fs.LogLevelDebug, "debug")
|
||||
logAt(ctx, fs.LogLevelNotice, "second")
|
||||
f = next()
|
||||
assert.Equal(t, "second", f.Msg)
|
||||
}
|
||||
|
||||
func TestRcEventsFilter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setTestLogLevel(t, fs.LogLevelInfo)
|
||||
|
||||
next, stop := eventServer(t, rc.Params{"heartbeat": 0, "jobid": 1})
|
||||
defer stop()
|
||||
assert.Equal(t, "start", next().Type)
|
||||
|
||||
// Only the logs of job 1 are sent
|
||||
logAt(ctx, fs.LogLevelNotice, "unattributed")
|
||||
logAt(testAttributionCtx, fs.LogLevelNotice, "job 1")
|
||||
f := next()
|
||||
assert.Equal(t, "job 1", f.Msg)
|
||||
assert.Equal(t, int64(1), f.JobID)
|
||||
}
|
||||
|
||||
func TestRcEventsHeartbeat(t *testing.T) {
|
||||
setTestLogLevel(t, fs.LogLevelInfo)
|
||||
next, stop := eventServer(t, rc.Params{"heartbeat": 1})
|
||||
defer stop()
|
||||
assert.Equal(t, "start", next().Type)
|
||||
assert.Equal(t, "heartbeat", next().Type)
|
||||
}
|
||||
|
||||
func TestRcEventsSince(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setTestLogLevel(t, fs.LogLevelInfo)
|
||||
|
||||
// Needs the log buffer for the replay
|
||||
_, err := rc.Calls.Get("core/events").Fn(ctx, rc.Params{"_response": httptest.NewRecorder(), "since": 0})
|
||||
assert.Equal(t, ErrBufferDisabled, err)
|
||||
|
||||
oldRecent, oldSize := Recent, Opt.BufferSize
|
||||
t.Cleanup(func() {
|
||||
Recent, Opt.BufferSize = oldRecent, oldSize
|
||||
setBufferSize()
|
||||
})
|
||||
Recent = newTestBuffer(0)
|
||||
Opt.BufferSize = 64 * fs.Kibi
|
||||
setBufferSize()
|
||||
|
||||
logAt(ctx, fs.LogLevelNotice, "before the stream")
|
||||
since := Recent.Seq() - 1
|
||||
|
||||
next, stop := eventServer(t, rc.Params{"heartbeat": 0, "since": since})
|
||||
defer stop()
|
||||
assert.Equal(t, "start", next().Type)
|
||||
|
||||
// The entry from before the stream started is replayed first
|
||||
f := next()
|
||||
assert.Equal(t, "log", f.Type)
|
||||
assert.Equal(t, "before the stream", f.Msg)
|
||||
assert.Equal(t, since, f.Seq)
|
||||
|
||||
// then the new ones follow with no repeats
|
||||
logAt(ctx, fs.LogLevelNotice, "during the stream")
|
||||
f = next()
|
||||
assert.Equal(t, "during the stream", f.Msg)
|
||||
assert.Equal(t, since+1, f.Seq)
|
||||
}
|
||||
|
||||
// Check a subscriber which isn't reading gets told what it missed
|
||||
func TestRcEventsDropped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setTestLogLevel(t, fs.LogLevelInfo)
|
||||
|
||||
s, unsubscribe := subscribe(Filter{Level: slog.LevelDebug})
|
||||
defer unsubscribe()
|
||||
for i := range subscriberBacklog + 10 {
|
||||
logAt(ctx, fs.LogLevelNotice, fmt.Sprintf("msg%d", i))
|
||||
}
|
||||
assert.Equal(t, subscriberBacklog, len(s.frames))
|
||||
assert.Equal(t, int64(10), s.dropped)
|
||||
|
||||
// Read the backlog - the dropped marker comes after it, where
|
||||
// the gap in the stream is, and not before
|
||||
var msgs []string
|
||||
var dropped int64
|
||||
for range subscriberBacklog {
|
||||
var f frame
|
||||
require.NoError(t, json.Unmarshal(<-s.frames, &f))
|
||||
if f.Type == "dropped" {
|
||||
dropped = f.Count
|
||||
} else {
|
||||
msgs = append(msgs, f.Msg)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, int64(0), dropped, "nothing is reported before the backlog is read")
|
||||
assert.Equal(t, "msg0", msgs[0])
|
||||
|
||||
// The marker is queued as soon as there is room for it
|
||||
logAt(ctx, fs.LogLevelNotice, "after the gap")
|
||||
var f frame
|
||||
require.NoError(t, json.Unmarshal(<-s.frames, &f))
|
||||
assert.Equal(t, "dropped", f.Type)
|
||||
assert.Equal(t, int64(10), f.Count)
|
||||
require.NoError(t, json.Unmarshal(<-s.frames, &f))
|
||||
assert.Equal(t, "after the gap", f.Msg)
|
||||
assert.Equal(t, int64(0), s.takeDropped())
|
||||
}
|
||||
|
||||
// Two streams must not write to each other's frames
|
||||
func TestRcEventsTwoStreams(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setTestLogLevel(t, fs.LogLevelInfo)
|
||||
|
||||
next1, stop1 := eventServer(t, rc.Params{"heartbeat": 0})
|
||||
defer stop1()
|
||||
next2, stop2 := eventServer(t, rc.Params{"heartbeat": 0})
|
||||
defer stop2()
|
||||
assert.Equal(t, "start", next1().Type)
|
||||
assert.Equal(t, "start", next2().Type)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, next := range []func() frame{next1, next2} {
|
||||
wg.Go(func() {
|
||||
for range 100 {
|
||||
f := next()
|
||||
assert.Equal(t, "log", f.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
for i := range 100 {
|
||||
logAt(ctx, fs.LogLevelNotice, fmt.Sprintf("msg%d", i))
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Check the log output is only connected while it is needed
|
||||
func TestSubscribeConnectsOutput(t *testing.T) {
|
||||
outputs := func() int {
|
||||
Handler.mu.Lock()
|
||||
defer Handler.mu.Unlock()
|
||||
return len(Handler.outputExtra)
|
||||
}
|
||||
before := outputs()
|
||||
s1, unsubscribe1 := subscribe(Filter{Level: slog.LevelDebug})
|
||||
assert.Equal(t, before+1, outputs())
|
||||
_, unsubscribe2 := subscribe(Filter{Level: slog.LevelDebug})
|
||||
assert.Equal(t, before+1, outputs(), "one output serves all the subscribers")
|
||||
unsubscribe1()
|
||||
assert.Equal(t, before+1, outputs())
|
||||
unsubscribe2()
|
||||
assert.Equal(t, before, outputs())
|
||||
assert.Empty(t, s1.frames)
|
||||
}
|
||||
|
||||
// setTestLogLevel sets the log level for the duration of the test
|
||||
func setTestLogLevel(t *testing.T, level fs.LogLevel) {
|
||||
ci := fs.GetConfig(context.Background())
|
||||
oldLevel := ci.LogLevel
|
||||
oldSlogLevel := Handler.SetLevel(fs.LogLevelToSlog(level))
|
||||
ci.LogLevel = level
|
||||
t.Cleanup(func() {
|
||||
ci.LogLevel = oldLevel
|
||||
Handler.SetLevel(oldSlogLevel)
|
||||
})
|
||||
}
|
||||
+13
-1
@@ -47,6 +47,11 @@ var OptionsInfo = fs.Options{{
|
||||
Default: logFormatDate | logFormatTime,
|
||||
Help: "Comma separated list of log format options",
|
||||
Groups: "Logging",
|
||||
}, {
|
||||
Name: "log_buffer_size",
|
||||
Default: fs.SizeSuffix(0),
|
||||
Help: "Keep this much of the most recent logs in memory for reading with the rc (0 to disable)",
|
||||
Groups: "Logging,RC",
|
||||
}, {
|
||||
Name: "syslog",
|
||||
Default: false,
|
||||
@@ -83,6 +88,7 @@ type Options struct {
|
||||
MaxAge fs.Duration `config:"log_file_max_age"` // Max age of log file
|
||||
Compress bool `config:"log_file_compress"` // Set to compress log file
|
||||
Format logFormat `config:"log_format"` // Comma separated list of log format options
|
||||
BufferSize fs.SizeSuffix `config:"log_buffer_size"` // Size of the in memory buffer of recent logs
|
||||
UseSyslog bool `config:"syslog"` // Use Syslog for logging
|
||||
SyslogFacility string `config:"syslog_facility"` // Facility for syslog, e.g. KERN,USER,...
|
||||
LogSystemdSupport bool `config:"log_systemd"` // set if using systemd logging
|
||||
@@ -90,7 +96,13 @@ type Options struct {
|
||||
}
|
||||
|
||||
func init() {
|
||||
fs.RegisterGlobalOptions(fs.OptionsInfo{Name: "log", Opt: &Opt, Options: OptionsInfo})
|
||||
fs.RegisterGlobalOptions(fs.OptionsInfo{Name: "log", Opt: &Opt, Options: OptionsInfo, Reload: optReload})
|
||||
}
|
||||
|
||||
// optReload makes the options which can be changed at runtime live.
|
||||
func optReload(ctx context.Context) error {
|
||||
setBufferSize()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Opt is the options for the logger
|
||||
|
||||
+71
-9
@@ -10,6 +10,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -139,9 +140,9 @@ type OutputHandler struct {
|
||||
levelVar slog.LevelVar
|
||||
writer io.Writer
|
||||
mu sync.Mutex
|
||||
output []outputFn // log to writer if empty or the last item
|
||||
outputExtra []outputExtra // log to all these additional places
|
||||
format logFormat // protected by mu
|
||||
output []outputFn // log to writer if empty or the last item
|
||||
outputExtra []*outputExtra // log to all these additional places
|
||||
format logFormat // protected by mu
|
||||
jsonBuf bytes.Buffer
|
||||
jsonHandler *slog.JSONHandler
|
||||
}
|
||||
@@ -149,7 +150,37 @@ type OutputHandler struct {
|
||||
// Records the type and function pointer for extra logging output.
|
||||
type outputExtra struct {
|
||||
json bool
|
||||
output outputFn
|
||||
output outputRecordFn
|
||||
}
|
||||
|
||||
// Define the type of an extra logging output which is passed the
|
||||
// record and what the log is attributed to
|
||||
type outputRecordFn func(r slog.Record, attribution Attribution, text string)
|
||||
|
||||
// Attribution records what made a log entry.
|
||||
//
|
||||
// Logs made with the context aware log functions, e.g. fs.DebugfCtx,
|
||||
// are attributed to the rc job and stats group in their context.
|
||||
type Attribution struct {
|
||||
JobID int64 // ID of the rc job or 0 if not from an rc job
|
||||
Group string // stats group or "" if not in a stats group
|
||||
}
|
||||
|
||||
// Attributed returns whether the log was attributed to anything.
|
||||
func (a Attribution) Attributed() bool {
|
||||
return a.JobID != 0 || a.Group != ""
|
||||
}
|
||||
|
||||
// attributionFromContext works out what to attribute a log made with
|
||||
// ctx to.
|
||||
func attributionFromContext(ctx context.Context) (attribution Attribution) {
|
||||
if group, ok := fs.StatsGroupFromContext(ctx); ok {
|
||||
attribution.Group = group
|
||||
}
|
||||
if jobID, ok := fs.JobIDFromContext(ctx); ok {
|
||||
attribution.JobID = jobID
|
||||
}
|
||||
return attribution
|
||||
}
|
||||
|
||||
// Define the type of the override logger
|
||||
@@ -213,13 +244,31 @@ func (h *OutputHandler) ResetOutput() {
|
||||
}
|
||||
|
||||
// AddOutput adds an additional logging destination of the type specified.
|
||||
func (h *OutputHandler) AddOutput(json bool, fn outputFn) {
|
||||
//
|
||||
// It returns a function which removes the destination again.
|
||||
func (h *OutputHandler) AddOutput(json bool, fn outputFn) (remove func()) {
|
||||
return h.addRecordOutput(json, func(r slog.Record, attribution Attribution, text string) {
|
||||
fn(r.Level, text)
|
||||
})
|
||||
}
|
||||
|
||||
// addRecordOutput is AddOutput with the output passed the log record.
|
||||
func (h *OutputHandler) addRecordOutput(json bool, fn outputRecordFn) (remove func()) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.outputExtra = append(h.outputExtra, outputExtra{
|
||||
out := &outputExtra{
|
||||
json: json,
|
||||
output: fn,
|
||||
})
|
||||
}
|
||||
h.outputExtra = append(h.outputExtra, out)
|
||||
return func() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
// Make a new slice as Handle may be using the old one
|
||||
h.outputExtra = slices.DeleteFunc(slices.Clone(h.outputExtra), func(x *outputExtra) bool {
|
||||
return x == out
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SetLevel sets a new log level, returning the old one.
|
||||
@@ -380,6 +429,19 @@ func (h *OutputHandler) Handle(ctx context.Context, r slog.Record) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Work out what this log is attributed to and record it in the
|
||||
// JSON output. The text output doesn't show it.
|
||||
var attribution Attribution
|
||||
if needJSON {
|
||||
attribution = attributionFromContext(ctx)
|
||||
if attribution.JobID != 0 {
|
||||
r.AddAttrs(slog.Int64("jobid", attribution.JobID))
|
||||
}
|
||||
if attribution.Group != "" {
|
||||
r.AddAttrs(slog.String("group", attribution.Group))
|
||||
}
|
||||
}
|
||||
|
||||
if needJSON {
|
||||
var bufJSONBack [256]byte
|
||||
bufJSON = bytes.NewBuffer(bufJSONBack[:0])
|
||||
@@ -417,9 +479,9 @@ func (h *OutputHandler) Handle(ctx context.Context, r slog.Record) (err error) {
|
||||
// Log to any additional destinations required
|
||||
for _, out := range outputExtra {
|
||||
if out.json {
|
||||
out.output(r.Level, bufJSON.String())
|
||||
out.output(r, attribution, bufJSON.String())
|
||||
} else {
|
||||
out.output(r.Level, bufText.String())
|
||||
out.output(r, attribution, bufText.String())
|
||||
}
|
||||
}
|
||||
return err
|
||||
|
||||
+13
-3
@@ -182,12 +182,21 @@ func TestAddOutput(t *testing.T) {
|
||||
extraText = txt
|
||||
}
|
||||
|
||||
h.AddOutput(false, out)
|
||||
remove := h.AddOutput(false, out)
|
||||
|
||||
r := slog.NewRecord(t0, slog.LevelInfo, "world", 0)
|
||||
require.NoError(t, h.Handle(context.Background(), r))
|
||||
assert.Equal(t, "2020/01/02 03:04:05 INFO : world\n", buf.String())
|
||||
assert.Equal(t, "2020/01/02 03:04:05 INFO : world\n", extraText)
|
||||
|
||||
// Check the output can be removed again
|
||||
remove()
|
||||
extraText = ""
|
||||
r = slog.NewRecord(t0, slog.LevelInfo, "again", 0)
|
||||
require.NoError(t, h.Handle(context.Background(), r))
|
||||
assert.Equal(t, "2020/01/02 03:04:05 INFO : world\n2020/01/02 03:04:05 INFO : again\n", buf.String())
|
||||
assert.Equal(t, "", extraText)
|
||||
assert.Empty(t, h.outputExtra)
|
||||
}
|
||||
|
||||
// Test AddOutputJSON sends JSON to extra destinations.
|
||||
@@ -346,12 +355,13 @@ func TestOutputHandlerConcurrency(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Goroutine calling AddOutput alternating JSON and text
|
||||
// Goroutine calling AddOutput alternating JSON and text and removing them
|
||||
wg.Go(func() {
|
||||
noop := func(_ slog.Level, _ string) {}
|
||||
for j := range 20 {
|
||||
h.AddOutput(j%2 == 0, noop)
|
||||
remove := h.AddOutput(j%2 == 0, noop)
|
||||
time.Sleep(time.Millisecond)
|
||||
remove()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+80
-3
@@ -2,6 +2,7 @@ package fs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -41,19 +42,19 @@ func TestLogSlogWithObject(t *testing.T) {
|
||||
SetLogger(slog.NewTextHandler(&buf, nil))
|
||||
|
||||
// Objects with a String method are rendered with it
|
||||
logSlogWithObject(LogLevelError, withString{}, "message", nil)
|
||||
logSlogWithObject(context.Background(), LogLevelError, withString{}, "message", nil)
|
||||
assert.Contains(t, buf.String(), "object=hello")
|
||||
|
||||
// Plain strings are rendered as themselves
|
||||
buf.Reset()
|
||||
logSlogWithObject(LogLevelError, "potato", "message", nil)
|
||||
logSlogWithObject(context.Background(), LogLevelError, "potato", "message", nil)
|
||||
assert.Contains(t, buf.String(), "object=potato")
|
||||
|
||||
// Anything else shows only its type as it may contain
|
||||
// sensitive data such as credentials
|
||||
buf.Reset()
|
||||
type secrets struct{ Password string }
|
||||
logSlogWithObject(LogLevelError, &secrets{Password: "SECRET"}, "message", nil)
|
||||
logSlogWithObject(context.Background(), LogLevelError, &secrets{Password: "SECRET"}, "message", nil)
|
||||
assert.Contains(t, buf.String(), "object=*fs.secrets")
|
||||
assert.NotContains(t, buf.String(), "SECRET")
|
||||
}
|
||||
@@ -119,3 +120,79 @@ func TestLogLevelUnmarshalJSON(t *testing.T) {
|
||||
assert.Equal(t, test.want, logLevel, test.in)
|
||||
}
|
||||
}
|
||||
|
||||
type testGroupKey struct{}
|
||||
|
||||
// ctxHandler records the contexts it was called with
|
||||
type ctxHandler struct {
|
||||
slog.Handler
|
||||
ctxs []context.Context
|
||||
}
|
||||
|
||||
func (h *ctxHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
h.ctxs = append(h.ctxs, ctx)
|
||||
return h.Handler.Handle(ctx, r)
|
||||
}
|
||||
|
||||
// The context aware log functions pass their context to the log
|
||||
// handler which uses it to attribute the log.
|
||||
func TestLogCtx(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
oldLogger := logger
|
||||
defer func() { logger = oldLogger }()
|
||||
h := &ctxHandler{Handler: slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})}
|
||||
SetLogger(h)
|
||||
ci := GetConfig(context.Background())
|
||||
oldLogLevel := ci.LogLevel
|
||||
ci.LogLevel = LogLevelDebug
|
||||
defer func() { ci.LogLevel = oldLogLevel }()
|
||||
|
||||
groupCtx := context.WithValue(context.Background(), testGroupKey{}, "job/1")
|
||||
check := func(want string) {
|
||||
t.Helper()
|
||||
var got struct {
|
||||
Level string `json:"level"`
|
||||
Msg string `json:"msg"`
|
||||
Object string `json:"object"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &got), buf.String())
|
||||
assert.Equal(t, want, fmt.Sprintf("%s|%s|%s|%d", got.Level, got.Msg, got.Object, got.Size))
|
||||
buf.Reset()
|
||||
}
|
||||
|
||||
ErrorfCtx(groupCtx, "obj", "error %d", 1)
|
||||
check("ERROR|error 1|obj|0")
|
||||
LogfCtx(groupCtx, nil, "notice")
|
||||
check("INFO+2|notice||0")
|
||||
InfofCtx(groupCtx, nil, "info")
|
||||
check("INFO|info||0")
|
||||
DebugfCtx(groupCtx, nil, "debug")
|
||||
check("DEBUG|debug||0")
|
||||
LogPrintfCtx(groupCtx, LogLevelInfo, nil, "size %v", LogValue("size", 3))
|
||||
check("INFO|size 3||3")
|
||||
|
||||
// All of those were passed the context they were called with
|
||||
require.Len(t, h.ctxs, 5)
|
||||
for _, ctx := range h.ctxs {
|
||||
assert.Equal(t, "job/1", ctx.Value(testGroupKey{}))
|
||||
}
|
||||
|
||||
// The non context functions and a nil context log with a
|
||||
// context which isn't attributed to anything
|
||||
h.ctxs = nil
|
||||
Errorf(nil, "plain")
|
||||
check("ERROR|plain||0")
|
||||
ErrorfCtx(nil, nil, "nil ctx") //nolint:staticcheck // deliberately testing a nil context
|
||||
check("ERROR|nil ctx||0")
|
||||
require.Len(t, h.ctxs, 2)
|
||||
for _, ctx := range h.ctxs {
|
||||
require.NotNil(t, ctx)
|
||||
assert.Nil(t, ctx.Value(testGroupKey{}))
|
||||
}
|
||||
|
||||
// Logs below the log level are dropped
|
||||
ci.LogLevel = LogLevelNotice
|
||||
InfofCtx(groupCtx, nil, "dropped")
|
||||
assert.Equal(t, "", buf.String())
|
||||
}
|
||||
+6
-6
@@ -347,7 +347,7 @@ func (m *March) matchListings(srcChan, dstChan <-chan fs.DirEntry, dstCancel fun
|
||||
}
|
||||
if src != nil && srcPrev != nil {
|
||||
if srcName == srcPrevName && fs.DirEntryType(srcPrev) == fs.DirEntryType(src) {
|
||||
fs.Logf(src, "Duplicate %s found in source - ignoring", fs.DirEntryType(src))
|
||||
fs.LogfCtx(m.Ctx, src, "Duplicate %s found in source - ignoring", fs.DirEntryType(src))
|
||||
srcDone() // skip the src and retry the dst
|
||||
continue
|
||||
} else if srcName < srcPrevName {
|
||||
@@ -357,7 +357,7 @@ func (m *March) matchListings(srcChan, dstChan <-chan fs.DirEntry, dstCancel fun
|
||||
}
|
||||
if dst != nil && dstPrev != nil {
|
||||
if dstName == dstPrevName && fs.DirEntryType(dst) == fs.DirEntryType(dstPrev) {
|
||||
fs.Logf(dst, "Duplicate %s found in destination - ignoring", fs.DirEntryType(dst))
|
||||
fs.LogfCtx(m.Ctx, dst, "Duplicate %s found in destination - ignoring", fs.DirEntryType(dst))
|
||||
dstDone() // skip the dst and retry the src
|
||||
continue
|
||||
} else if dstName < dstPrevName {
|
||||
@@ -563,9 +563,9 @@ func (m *March) processJob(job listDirJob) ([]listDirJob, error) {
|
||||
wg.Wait()
|
||||
if srcListErr != nil {
|
||||
if job.srcRemote != "" {
|
||||
fs.Errorf(job.srcRemote, "error reading source directory: %v", srcListErr)
|
||||
fs.ErrorfCtx(m.Ctx, job.srcRemote, "error reading source directory: %v", srcListErr)
|
||||
} else {
|
||||
fs.Errorf(m.Fsrc, "error reading source root directory: %v", srcListErr)
|
||||
fs.ErrorfCtx(m.Ctx, m.Fsrc, "error reading source root directory: %v", srcListErr)
|
||||
}
|
||||
srcListErr = fs.CountError(m.Ctx, srcListErr)
|
||||
return nil, srcListErr
|
||||
@@ -576,9 +576,9 @@ func (m *March) processJob(job listDirJob) ([]listDirJob, error) {
|
||||
// Copy the stuff anyway
|
||||
} else if dstListErr != nil {
|
||||
if job.dstRemote != "" {
|
||||
fs.Errorf(job.dstRemote, "error reading destination directory: %v", dstListErr)
|
||||
fs.ErrorfCtx(m.Ctx, job.dstRemote, "error reading destination directory: %v", dstListErr)
|
||||
} else {
|
||||
fs.Errorf(m.Fdst, "error reading destination root directory: %v", dstListErr)
|
||||
fs.ErrorfCtx(m.Ctx, m.Fdst, "error reading destination root directory: %v", dstListErr)
|
||||
}
|
||||
dstListErr = fs.CountError(m.Ctx, dstListErr)
|
||||
return nil, dstListErr
|
||||
|
||||
+30
-30
@@ -83,7 +83,7 @@ func (c *checkMarch) DstOnly(dst fs.DirEntry) (recurse bool) {
|
||||
return false
|
||||
}
|
||||
err := fmt.Errorf("file not in %v", c.opt.Fsrc)
|
||||
fs.Errorf(dst, "%v", err)
|
||||
fs.ErrorfCtx(c.ctx, dst, "%v", err)
|
||||
_ = fs.CountError(c.ctx, err)
|
||||
c.differences.Add(1)
|
||||
c.srcFilesMissing.Add(1)
|
||||
@@ -105,7 +105,7 @@ func (c *checkMarch) SrcOnly(src fs.DirEntry) (recurse bool) {
|
||||
switch src.(type) {
|
||||
case fs.Object:
|
||||
err := fmt.Errorf("file not in %v", c.opt.Fdst)
|
||||
fs.Errorf(src, "%v", err)
|
||||
fs.ErrorfCtx(c.ctx, src, "%v", err)
|
||||
_ = fs.CountError(c.ctx, err)
|
||||
c.differences.Add(1)
|
||||
c.dstFilesMissing.Add(1)
|
||||
@@ -128,7 +128,7 @@ func (c *checkMarch) checkIdentical(ctx context.Context, dst, src fs.Object) (di
|
||||
}()
|
||||
if sizeDiffers(ctx, src, dst) {
|
||||
err = fmt.Errorf("sizes differ")
|
||||
fs.Errorf(src, "%v", err)
|
||||
fs.ErrorfCtx(ctx, src, "%v", err)
|
||||
return true, false, nil
|
||||
}
|
||||
if ci.SizeOnly {
|
||||
@@ -155,7 +155,7 @@ func (c *checkMarch) Match(ctx context.Context, dst, src fs.DirEntry) (recurse b
|
||||
}()
|
||||
differ, noHash, err := c.checkIdentical(ctx, dstX, srcX)
|
||||
if err != nil {
|
||||
fs.Errorf(src, "%v", err)
|
||||
fs.ErrorfCtx(ctx, src, "%v", err)
|
||||
_ = fs.CountError(ctx, err)
|
||||
c.report(src, c.opt.Error, '!')
|
||||
} else if differ {
|
||||
@@ -169,15 +169,15 @@ func (c *checkMarch) Match(ctx context.Context, dst, src fs.DirEntry) (recurse b
|
||||
c.report(src, c.opt.Match, '=')
|
||||
if noHash {
|
||||
c.noHashes.Add(1)
|
||||
fs.Debugf(dstX, "OK - could not check hash")
|
||||
fs.DebugfCtx(ctx, dstX, "OK - could not check hash")
|
||||
} else {
|
||||
fs.Debugf(dstX, "OK")
|
||||
fs.DebugfCtx(ctx, dstX, "OK")
|
||||
}
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
err := fmt.Errorf("is file on %v but directory on %v", c.opt.Fsrc, c.opt.Fdst)
|
||||
fs.Errorf(src, "%v", err)
|
||||
fs.ErrorfCtx(ctx, src, "%v", err)
|
||||
_ = fs.CountError(ctx, err)
|
||||
c.differences.Add(1)
|
||||
c.dstFilesMissing.Add(1)
|
||||
@@ -190,7 +190,7 @@ func (c *checkMarch) Match(ctx context.Context, dst, src fs.DirEntry) (recurse b
|
||||
return true
|
||||
}
|
||||
err := fmt.Errorf("is file on %v but directory on %v", c.opt.Fdst, c.opt.Fsrc)
|
||||
fs.Errorf(dst, "%v", err)
|
||||
fs.ErrorfCtx(ctx, dst, "%v", err)
|
||||
_ = fs.CountError(ctx, err)
|
||||
c.differences.Add(1)
|
||||
c.srcFilesMissing.Add(1)
|
||||
@@ -230,7 +230,7 @@ func CheckFn(ctx context.Context, opt *CheckOpt) error {
|
||||
NoTraverse: ci.NoTraverse,
|
||||
NoUnicodeNormalization: ci.NoUnicodeNormalization,
|
||||
}
|
||||
fs.Debugf(c.opt.Fdst, "Waiting for checks to finish")
|
||||
fs.DebugfCtx(ctx, c.opt.Fdst, "Waiting for checks to finish")
|
||||
err := m.Run(ctx)
|
||||
c.wg.Wait() // wait for background go-routines
|
||||
|
||||
@@ -239,25 +239,25 @@ func CheckFn(ctx context.Context, opt *CheckOpt) error {
|
||||
|
||||
func (c *checkMarch) reportResults(ctx context.Context, err error) error {
|
||||
if c.dstFilesMissing.Load() > 0 {
|
||||
fs.Logf(c.opt.Fdst, "%d files missing", c.dstFilesMissing.Load())
|
||||
fs.LogfCtx(ctx, c.opt.Fdst, "%d files missing", c.dstFilesMissing.Load())
|
||||
}
|
||||
if c.srcFilesMissing.Load() > 0 {
|
||||
entity := "files"
|
||||
if c.opt.Fsrc == nil {
|
||||
entity = "hashes"
|
||||
}
|
||||
fs.Logf(c.opt.Fsrc, "%d %s missing", c.srcFilesMissing.Load(), entity)
|
||||
fs.LogfCtx(ctx, c.opt.Fsrc, "%d %s missing", c.srcFilesMissing.Load(), entity)
|
||||
}
|
||||
|
||||
fs.Logf(c.opt.Fdst, "%d differences found", c.differences.Load())
|
||||
fs.LogfCtx(ctx, c.opt.Fdst, "%d differences found", c.differences.Load())
|
||||
if errs := accounting.Stats(ctx).GetErrors(); errs > 0 {
|
||||
fs.Logf(c.opt.Fdst, "%d errors while checking", errs)
|
||||
fs.LogfCtx(ctx, c.opt.Fdst, "%d errors while checking", errs)
|
||||
}
|
||||
if c.noHashes.Load() > 0 {
|
||||
fs.Logf(c.opt.Fdst, "%d hashes could not be checked", c.noHashes.Load())
|
||||
fs.LogfCtx(ctx, c.opt.Fdst, "%d hashes could not be checked", c.noHashes.Load())
|
||||
}
|
||||
if c.matches.Load() > 0 {
|
||||
fs.Logf(c.opt.Fdst, "%d matching files", c.matches.Load())
|
||||
fs.LogfCtx(ctx, c.opt.Fdst, "%d matching files", c.matches.Load())
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -284,7 +284,7 @@ func Check(ctx context.Context, opt *CheckOpt) error {
|
||||
}
|
||||
if !same {
|
||||
err = fmt.Errorf("%v differ", ht)
|
||||
fs.Errorf(src, "%v", err)
|
||||
fs.ErrorfCtx(ctx, src, "%v", err)
|
||||
return true, false, nil
|
||||
}
|
||||
return false, false, nil
|
||||
@@ -375,7 +375,7 @@ func CheckDownload(ctx context.Context, opt *CheckOpt) error {
|
||||
}
|
||||
if !same {
|
||||
err = errors.New("contents differ")
|
||||
fs.Errorf(src, "%v", err)
|
||||
fs.ErrorfCtx(ctx, src, "%v", err)
|
||||
return true, false, nil
|
||||
}
|
||||
return false, false, nil
|
||||
@@ -457,7 +457,7 @@ func CheckSum(ctx context.Context, fsrc, fsum fs.Fs, sumFile string, hashType ha
|
||||
}
|
||||
// filesystem missed the file, sum wasn't consumed
|
||||
err := fmt.Errorf("file not in %v", opt.Fdst)
|
||||
fs.Errorf(filename, "%v", err)
|
||||
fs.ErrorfCtx(ctx, filename, "%v", err)
|
||||
_ = fs.CountError(ctx, err)
|
||||
if lastErr == nil {
|
||||
lastErr = err
|
||||
@@ -486,7 +486,7 @@ func (c *checkMarch) checkSum(ctx context.Context, obj fs.Object, download bool,
|
||||
if !sumFound {
|
||||
err := errors.New("sum not found")
|
||||
_ = fs.CountError(ctx, err)
|
||||
fs.Errorf(obj, "%v", err)
|
||||
fs.ErrorfCtx(ctx, obj, "%v", err)
|
||||
c.differences.Add(1)
|
||||
c.srcFilesMissing.Add(1)
|
||||
c.report(obj, c.opt.MissingOnSrc, '-')
|
||||
@@ -534,29 +534,29 @@ func (c *checkMarch) matchSum(ctx context.Context, sumHash, objHash string, obj
|
||||
switch {
|
||||
case err != nil:
|
||||
_ = fs.CountError(ctx, err)
|
||||
fs.Errorf(obj, "Failed to calculate hash: %v", err)
|
||||
fs.ErrorfCtx(ctx, obj, "Failed to calculate hash: %v", err)
|
||||
c.report(obj, c.opt.Error, '!')
|
||||
case sumHash == "":
|
||||
err = errors.New("duplicate file")
|
||||
_ = fs.CountError(ctx, err)
|
||||
fs.Errorf(obj, "%v", err)
|
||||
fs.ErrorfCtx(ctx, obj, "%v", err)
|
||||
c.report(obj, c.opt.Error, '!')
|
||||
case objHash == "":
|
||||
fs.Debugf(nil, "%v = %s (sum)", hashType, sumHash)
|
||||
fs.Debugf(obj, "%v - could not check hash (%v)", hashType, c.opt.Fdst)
|
||||
fs.DebugfCtx(ctx, nil, "%v = %s (sum)", hashType, sumHash)
|
||||
fs.DebugfCtx(ctx, obj, "%v - could not check hash (%v)", hashType, c.opt.Fdst)
|
||||
c.noHashes.Add(1)
|
||||
c.matches.Add(1)
|
||||
c.report(obj, c.opt.Match, '=')
|
||||
case objHash == sumHash:
|
||||
fs.Debugf(obj, "%v = %s OK", hashType, sumHash)
|
||||
fs.DebugfCtx(ctx, obj, "%v = %s OK", hashType, sumHash)
|
||||
c.matches.Add(1)
|
||||
c.report(obj, c.opt.Match, '=')
|
||||
default:
|
||||
err = errors.New("files differ")
|
||||
_ = fs.CountError(ctx, err)
|
||||
fs.Debugf(nil, "%v = %s (sum)", hashType, sumHash)
|
||||
fs.Debugf(obj, "%v = %s (%v)", hashType, objHash, c.opt.Fdst)
|
||||
fs.Errorf(obj, "%v", err)
|
||||
fs.DebugfCtx(ctx, nil, "%v = %s (sum)", hashType, sumHash)
|
||||
fs.DebugfCtx(ctx, obj, "%v = %s (%v)", hashType, objHash, c.opt.Fdst)
|
||||
fs.ErrorfCtx(ctx, obj, "%v", err)
|
||||
c.differences.Add(1)
|
||||
c.report(obj, c.opt.Differ, '*')
|
||||
}
|
||||
@@ -595,7 +595,7 @@ func ParseSumFile(ctx context.Context, sumFile fs.Object) (HashSums, error) {
|
||||
if fields == nil {
|
||||
numWarn++
|
||||
if numWarn <= maxWarn {
|
||||
fs.Logf(sumFile, "improperly formatted checksum line %d", lineNo)
|
||||
fs.LogfCtx(ctx, sumFile, "improperly formatted checksum line %d", lineNo)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -604,7 +604,7 @@ func ParseSumFile(ctx context.Context, sumFile fs.Object) (HashSums, error) {
|
||||
if hashes[file] != "" {
|
||||
numWarn++
|
||||
if numWarn <= maxWarn {
|
||||
fs.Logf(sumFile, "duplicate file on checksum line %d", lineNo)
|
||||
fs.LogfCtx(ctx, sumFile, "duplicate file on checksum line %d", lineNo)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -614,7 +614,7 @@ func ParseSumFile(ctx context.Context, sumFile fs.Object) (HashSums, error) {
|
||||
}
|
||||
|
||||
if numWarn > maxWarn {
|
||||
fs.Logf(sumFile, "%d warning(s) suppressed...", numWarn-maxWarn)
|
||||
fs.LogfCtx(ctx, sumFile, "%d warning(s) suppressed...", numWarn-maxWarn)
|
||||
}
|
||||
if err = rd.Close(); err != nil {
|
||||
return nil, err
|
||||
|
||||
+12
-12
@@ -45,10 +45,10 @@ func (c *copy) removeFailedCopy(ctx context.Context, o fs.Object) {
|
||||
if o == nil {
|
||||
return
|
||||
}
|
||||
fs.Infof(o, "Removing failed copy")
|
||||
fs.InfofCtx(ctx, o, "Removing failed copy")
|
||||
err := o.Remove(ctx)
|
||||
if err != nil {
|
||||
fs.Infof(o, "Failed to remove failed copy: %s", err)
|
||||
fs.InfofCtx(ctx, o, "Failed to remove failed copy: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (c *copy) removeFailedPartialCopy(ctx context.Context, f fs.Fs, remote stri
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fs.Infof(remote, "Failed to remove failed partial copy: %s", err)
|
||||
fs.InfofCtx(ctx, remote, "Failed to remove failed partial copy: %s", err)
|
||||
return
|
||||
}
|
||||
c.removeFailedCopy(ctx, o)
|
||||
@@ -190,7 +190,7 @@ func (c *copy) rcat(ctx context.Context, in io.ReadCloser) (actionTaken string,
|
||||
if c.ci.Metadata {
|
||||
meta, err = fs.GetMetadata(ctx, c.src)
|
||||
if err != nil {
|
||||
fs.Errorf(c.src, "Failed to read metadata: %v", err)
|
||||
fs.ErrorfCtx(ctx, c.src, "Failed to read metadata: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func (c *copy) manualCopy(ctx context.Context) (actionTaken string, newDst fs.Ob
|
||||
if !errors.Is(err, fs.ErrorRangeIgnored) {
|
||||
return actionTaken, newDst, err
|
||||
}
|
||||
fs.Logf(c.src, "multi-thread copy: %v: downloading in a single stream", err)
|
||||
fs.LogfCtx(ctx, c.src, "multi-thread copy: %v: downloading in a single stream", err)
|
||||
c.tr.Reset(ctx)
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@ func (c *copy) copy(ctx context.Context) (newDst fs.Object, err error) {
|
||||
if fserrors.IsRetryError(err) || fserrors.ShouldRetry(err) {
|
||||
retry = true
|
||||
} else if t, ok := pacer.IsRetryAfter(err); ok && tries+1 < c.maxTries {
|
||||
fs.Debugf(c.src, "Sleeping for %v (as indicated by the server) to obey Retry-After error: %v", t, err)
|
||||
fs.DebugfCtx(ctx, c.src, "Sleeping for %v (as indicated by the server) to obey Retry-After error: %v", t, err)
|
||||
if sleepWithContext(ctx, t) {
|
||||
retry = true
|
||||
} else {
|
||||
@@ -344,14 +344,14 @@ func (c *copy) copy(ctx context.Context) (newDst fs.Object, err error) {
|
||||
}
|
||||
}
|
||||
if retry {
|
||||
fs.Debugf(c.src, "Received error: %v - low level retry %d/%d", err, tries+1, c.maxTries)
|
||||
fs.DebugfCtx(ctx, c.src, "Received error: %v - low level retry %d/%d", err, tries+1, c.maxTries)
|
||||
c.tr.Reset(ctx) // skip incomplete accounting - will be overwritten by retry
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(c.src, "Failed to copy: %v", err)
|
||||
fs.ErrorfCtx(ctx, c.src, "Failed to copy: %v", err)
|
||||
if !c.inplace {
|
||||
c.removeFailedPartialCopy(ctx, c.f, c.remoteForCopy)
|
||||
}
|
||||
@@ -361,7 +361,7 @@ func (c *copy) copy(ctx context.Context) (newDst fs.Object, err error) {
|
||||
// Verify the copy
|
||||
err = c.verify(ctx, newDst)
|
||||
if err != nil {
|
||||
fs.Errorf(newDst, "%v", err)
|
||||
fs.ErrorfCtx(ctx, newDst, "%v", err)
|
||||
err = fs.CountError(ctx, err)
|
||||
c.removeFailedCopy(ctx, newDst)
|
||||
return nil, err
|
||||
@@ -371,12 +371,12 @@ func (c *copy) copy(ctx context.Context) (newDst fs.Object, err error) {
|
||||
if !c.inplace && c.remoteForCopy != c.remote {
|
||||
movedNewDst, err := c.dstFeatures.Move(ctx, newDst, c.remote)
|
||||
if err != nil {
|
||||
fs.Errorf(newDst, "partial file rename failed: %v", err)
|
||||
fs.ErrorfCtx(ctx, newDst, "partial file rename failed: %v", err)
|
||||
err = fs.CountError(ctx, err)
|
||||
c.removeFailedCopy(ctx, newDst)
|
||||
return nil, err
|
||||
}
|
||||
fs.Debugf(newDst, "renamed to: %s", c.remote)
|
||||
fs.DebugfCtx(ctx, newDst, "renamed to: %s", c.remote)
|
||||
newDst = movedNewDst
|
||||
}
|
||||
|
||||
@@ -384,7 +384,7 @@ func (c *copy) copy(ctx context.Context) (newDst fs.Object, err error) {
|
||||
if newDst != nil && c.src.String() != newDst.String() {
|
||||
actionTaken = fmt.Sprintf("%s to: %s", actionTaken, newDst.String())
|
||||
}
|
||||
fs.Infof(c.src, "%s%s", actionTaken, fs.LogValueHide("size", fs.SizeSuffix(c.src.Size())))
|
||||
fs.InfofCtx(ctx, c.src, "%s%s", actionTaken, fs.LogValueHide("size", fs.SizeSuffix(c.src.Size())))
|
||||
|
||||
return newDst, nil
|
||||
}
|
||||
|
||||
+14
-14
@@ -43,7 +43,7 @@ outer:
|
||||
for {
|
||||
suffix++
|
||||
if suffix > maxDedupeSuffix {
|
||||
fs.Errorf(o, "Could not find an available new name")
|
||||
fs.ErrorfCtx(ctx, o, "Could not find an available new name")
|
||||
continue outer
|
||||
}
|
||||
newName = fmt.Sprintf("%s-%d%s", base, suffix, ext)
|
||||
@@ -56,7 +56,7 @@ outer:
|
||||
}
|
||||
if err != nil {
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(o, "Failed to check for existing object: %v", err)
|
||||
fs.ErrorfCtx(ctx, o, "Failed to check for existing object: %v", err)
|
||||
continue outer
|
||||
}
|
||||
files[newName] = append(files[newName], existing)
|
||||
@@ -65,10 +65,10 @@ outer:
|
||||
newObj, err := doMove(ctx, o, newName)
|
||||
if err != nil {
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(o, "Failed to rename: %v", err)
|
||||
fs.ErrorfCtx(ctx, o, "Failed to rename: %v", err)
|
||||
continue
|
||||
}
|
||||
fs.Infof(newObj, "renamed from: %v", o)
|
||||
fs.InfofCtx(ctx, newObj, "renamed from: %v", o)
|
||||
files[newName] = append(files[newName], newObj)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func dedupeDeleteAllButOne(ctx context.Context, keep int, remote string, objs []
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
fs.Logf(remote, "Deleted %d extra copies", count)
|
||||
fs.LogfCtx(ctx, remote, "Deleted %d extra copies", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func dedupeDeleteIdentical(ctx context.Context, ht hash.Type, remote string, obj
|
||||
if IDs[ID] <= 1 {
|
||||
newObjs = append(newObjs, o)
|
||||
} else {
|
||||
fs.Logf(o, "Ignoring as it appears %d times in the listing and deleting would lead to data loss", IDs[ID])
|
||||
fs.LogfCtx(ctx, o, "Ignoring as it appears %d times in the listing and deleting would lead to data loss", IDs[ID])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func dedupeDeleteIdentical(ctx context.Context, ht hash.Type, remote string, obj
|
||||
for ID, dupes := range dupesByID {
|
||||
remainingObjs = append(remainingObjs, dupes[0])
|
||||
if len(dupes) > 1 {
|
||||
fs.Logf(remote, "Deleting %d/%d identical duplicates (%s)", len(dupes)-1, len(dupes), ID)
|
||||
fs.LogfCtx(ctx, remote, "Deleting %d/%d identical duplicates (%s)", len(dupes)-1, len(dupes), ID)
|
||||
for _, o := range dupes[1:] {
|
||||
err := DeleteFile(ctx, o)
|
||||
if err != nil {
|
||||
@@ -390,11 +390,11 @@ func dedupeMergeDuplicateDirs(ctx context.Context, f fs.Fs, duplicateDirs [][]*d
|
||||
}
|
||||
fsDirs[largestIdx], fsDirs[0] = fsDirs[0], fsDirs[largestIdx]
|
||||
|
||||
fs.Infof(fsDirs[0], "Merging contents of duplicate directories")
|
||||
fs.InfofCtx(ctx, fsDirs[0], "Merging contents of duplicate directories")
|
||||
err := mergeDirs(ctx, fsDirs)
|
||||
if err != nil {
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(nil, "merge duplicate dirs: %v", err)
|
||||
fs.ErrorfCtx(ctx, nil, "merge duplicate dirs: %v", err)
|
||||
}
|
||||
}
|
||||
dirCacheFlush()
|
||||
@@ -429,7 +429,7 @@ func Deduplicate(ctx context.Context, f fs.Fs, mode DeduplicateMode, byHash bool
|
||||
}
|
||||
what = ht.String() + " hashes"
|
||||
}
|
||||
fs.Infof(f, "Looking for duplicate %s using %v mode.", what, mode)
|
||||
fs.InfofCtx(ctx, f, "Looking for duplicate %s using %v mode.", what, mode)
|
||||
|
||||
// Find duplicate directories first and fix them
|
||||
if !byHash {
|
||||
@@ -464,7 +464,7 @@ func Deduplicate(ctx context.Context, f fs.Fs, mode DeduplicateMode, byHash bool
|
||||
if byHash {
|
||||
remote, err = o.Hash(ctx, ht)
|
||||
if err != nil {
|
||||
fs.Errorf(o, "Failed to hash: %v", err)
|
||||
fs.ErrorfCtx(ctx, o, "Failed to hash: %v", err)
|
||||
remote = ""
|
||||
}
|
||||
} else {
|
||||
@@ -487,11 +487,11 @@ func Deduplicate(ctx context.Context, f fs.Fs, mode DeduplicateMode, byHash bool
|
||||
if len(objs) <= 1 {
|
||||
continue
|
||||
}
|
||||
fs.Logf(remote, "Found %d files with duplicate %s", len(objs), what)
|
||||
fs.LogfCtx(ctx, remote, "Found %d files with duplicate %s", len(objs), what)
|
||||
if !byHash && mode != DeduplicateList {
|
||||
objs = dedupeDeleteIdentical(ctx, ht, remote, objs)
|
||||
if len(objs) <= 1 {
|
||||
fs.Logf(remote, "All duplicates removed")
|
||||
fs.LogfCtx(ctx, remote, "All duplicates removed")
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -517,7 +517,7 @@ func Deduplicate(ctx context.Context, f fs.Fs, mode DeduplicateMode, byHash bool
|
||||
sortSmallestFirst(objs)
|
||||
dedupeDeleteAllButOne(ctx, 0, remote, objs)
|
||||
case DeduplicateSkip:
|
||||
fs.Logf(remote, "Skipping %d files with duplicate %s", len(objs), what)
|
||||
fs.LogfCtx(ctx, remote, "Skipping %d files with duplicate %s", len(objs), what)
|
||||
case DeduplicateList:
|
||||
dedupeList(ctx, f, ht, remote, objs, byHash)
|
||||
default:
|
||||
|
||||
@@ -137,7 +137,7 @@ func NewDefaultLoggerFn(opt *LoggerOpt) LoggerFn {
|
||||
}
|
||||
if opt.Combined != nil {
|
||||
SyncFprintf(opt.Combined, "%c %s\n", sigil, filename)
|
||||
fs.Debugf(nil, "Sync Logger: %s: %c %s\n", sigil.String(), sigil, filename)
|
||||
fs.DebugfCtx(ctx, nil, "Sync Logger: %s: %c %s\n", sigil.String(), sigil, filename)
|
||||
}
|
||||
if opt.DestAfter != nil {
|
||||
opt.PrintDestAfter(ctx, sigil, src, dst, err)
|
||||
@@ -299,7 +299,7 @@ func WinningSide(ctx context.Context, sigil Sigil, src, dst fs.DirEntry, err err
|
||||
// should only make it this far if it's TransferError and both src and dst are nil
|
||||
winner.Side = "none"
|
||||
winner.Err = fmt.Errorf("unknown case -- can't determine winner. %v", err)
|
||||
fs.Debugf(winner.Obj, "%v", winner.Err)
|
||||
fs.DebugfCtx(ctx, winner.Obj, "%v", winner.Err)
|
||||
return winner
|
||||
}
|
||||
|
||||
@@ -372,13 +372,13 @@ func (opt *LoggerOpt) Init(ctx context.Context, fdst fs.Fs, cmdFlags *pflag.Flag
|
||||
|
||||
ci := fs.GetConfig(ctx)
|
||||
if ci.NoTraverse && opt.Combined != nil {
|
||||
fs.LogPrintf(fs.LogLevelWarning, nil, "--no-traverse does not list any deletes (-) in --combined output\n")
|
||||
fs.LogPrintfCtx(ctx, fs.LogLevelWarning, nil, "--no-traverse does not list any deletes (-) in --combined output\n")
|
||||
}
|
||||
if ci.NoTraverse && opt.MissingOnSrc != nil {
|
||||
fs.LogPrintf(fs.LogLevelWarning, nil, "--no-traverse makes --missing-on-src produce empty output\n")
|
||||
fs.LogPrintfCtx(ctx, fs.LogLevelWarning, nil, "--no-traverse makes --missing-on-src produce empty output\n")
|
||||
}
|
||||
if ci.NoTraverse && opt.DestAfter != nil {
|
||||
fs.LogPrintf(fs.LogLevelWarning, nil, "--no-traverse makes --dest-after produce incomplete output\n")
|
||||
fs.LogPrintfCtx(ctx, fs.LogLevelWarning, nil, "--no-traverse makes --dest-after produce incomplete output\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,7 +441,7 @@ func (opt *LoggerOpt) SetListFormat(ctx context.Context, cmdFlags *pflag.FlagSet
|
||||
list.AddMetadata()
|
||||
JSONOpt.Metadata = true
|
||||
default:
|
||||
fs.Errorf(nil, "unknown format character %q", char)
|
||||
fs.ErrorfCtx(ctx, nil, "unknown format character %q", char)
|
||||
}
|
||||
}
|
||||
opt.ListFormat = list
|
||||
|
||||
@@ -168,7 +168,7 @@ func (lj *listJSON) entry(ctx context.Context, entry fs.DirEntry) (*ListJSONItem
|
||||
return nil, nil
|
||||
}
|
||||
default:
|
||||
fs.Errorf(nil, "Unknown type %T in listing", entry)
|
||||
fs.ErrorfCtx(ctx, nil, "Unknown type %T in listing", entry)
|
||||
}
|
||||
|
||||
item := &ListJSONItem{
|
||||
@@ -192,14 +192,14 @@ func (lj *listJSON) entry(ctx context.Context, entry fs.DirEntry) (*ListJSONItem
|
||||
case fs.Object:
|
||||
item.EncryptedPath = lj.cipher.EncryptFileName(entry.Remote())
|
||||
default:
|
||||
fs.Errorf(nil, "Unknown type %T in listing", entry)
|
||||
fs.ErrorfCtx(ctx, nil, "Unknown type %T in listing", entry)
|
||||
}
|
||||
item.Encrypted = path.Base(item.EncryptedPath)
|
||||
}
|
||||
if lj.opt.Metadata {
|
||||
metadata, err := fs.GetMetadata(ctx, entry)
|
||||
if err != nil {
|
||||
fs.Errorf(entry, "Failed to read metadata: %v", err)
|
||||
fs.ErrorfCtx(ctx, entry, "Failed to read metadata: %v", err)
|
||||
} else if metadata != nil {
|
||||
item.Metadata = metadata
|
||||
}
|
||||
@@ -223,7 +223,7 @@ func (lj *listJSON) entry(ctx context.Context, entry fs.DirEntry) (*ListJSONItem
|
||||
for _, hashType := range lj.hashTypes {
|
||||
hash, err := x.Hash(ctx, hashType)
|
||||
if err != nil {
|
||||
fs.Errorf(x, "Failed to read hash: %v", err)
|
||||
fs.ErrorfCtx(ctx, x, "Failed to read hash: %v", err)
|
||||
} else if hash != "" {
|
||||
item.Hashes[hashType.String()] = hash
|
||||
}
|
||||
@@ -235,7 +235,7 @@ func (lj *listJSON) entry(ctx context.Context, entry fs.DirEntry) (*ListJSONItem
|
||||
}
|
||||
}
|
||||
default:
|
||||
fs.Errorf(nil, "Unknown type %T in listing in ListJSON", entry)
|
||||
fs.ErrorfCtx(ctx, nil, "Unknown type %T in listing in ListJSON", entry)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@ func (mc *multiThreadCopyState) copyChunk(ctx context.Context, chunk int, writer
|
||||
fs.CheckClose(rw, &err)
|
||||
}
|
||||
if err != nil {
|
||||
fs.Debugf(mc.src, "multi-thread copy: chunk %d/%d failed: %v", chunk+1, mc.numChunks, err)
|
||||
fs.DebugfCtx(ctx, mc.src, "multi-thread copy: chunk %d/%d failed: %v", chunk+1, mc.numChunks, err)
|
||||
}
|
||||
}()
|
||||
|
||||
fs.Debugf(mc.src, "multi-thread copy: chunk %d/%d (%d-%d) size %v starting", chunk+1, mc.numChunks, start, end, fs.SizeSuffix(size))
|
||||
fs.DebugfCtx(ctx, mc.src, "multi-thread copy: chunk %d/%d (%d-%d) size %v starting", chunk+1, mc.numChunks, start, end, fs.SizeSuffix(size))
|
||||
|
||||
rc, err := Open(ctx, mc.src, &fs.RangeOption{Start: start, End: end - 1})
|
||||
if err != nil {
|
||||
@@ -105,7 +105,7 @@ func (mc *multiThreadCopyState) copyChunk(ctx context.Context, chunk int, writer
|
||||
return fmt.Errorf("multi-thread copy: failed to write chunk: %w", err)
|
||||
}
|
||||
|
||||
fs.Debugf(mc.src, "multi-thread copy: chunk %d/%d (%d-%d) size %v finished", chunk+1, mc.numChunks, start, end, fs.SizeSuffix(bytesWritten))
|
||||
fs.DebugfCtx(ctx, mc.src, "multi-thread copy: chunk %d/%d (%d-%d) size %v finished", chunk+1, mc.numChunks, start, end, fs.SizeSuffix(bytesWritten))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -133,17 +133,17 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
|
||||
}
|
||||
openChunkWriter = openChunkWriterFromOpenWriterAt(openWriterAt, int64(ci.MultiThreadChunkSize), int64(ci.MultiThreadWriteBufferSize), f)
|
||||
// If we are using OpenWriterAt we don't seek the chunks so don't need to buffer
|
||||
fs.Debugf(src, "multi-thread copy: disabling buffering because destination uses OpenWriterAt")
|
||||
fs.DebugfCtx(ctx, src, "multi-thread copy: disabling buffering because destination uses OpenWriterAt")
|
||||
noBuffering = true
|
||||
usingOpenWriterAt = true
|
||||
} else if src.Fs().Features().IsLocal {
|
||||
// If the source fs is local we don't need to buffer
|
||||
fs.Debugf(src, "multi-thread copy: disabling buffering because source is local disk")
|
||||
fs.DebugfCtx(ctx, src, "multi-thread copy: disabling buffering because source is local disk")
|
||||
noBuffering = true
|
||||
} else if f.Features().ChunkWriterDoesntSeek {
|
||||
// If the destination Fs promises not to seek its chunks
|
||||
// (except for retries) then we don't need buffering.
|
||||
fs.Debugf(src, "multi-thread copy: disabling buffering because destination has set ChunkWriterDoesntSeek")
|
||||
fs.DebugfCtx(ctx, src, "multi-thread copy: disabling buffering because destination has set ChunkWriterDoesntSeek")
|
||||
noBuffering = true
|
||||
}
|
||||
|
||||
@@ -167,27 +167,27 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
|
||||
if info.LeavePartsOnError || uploadedOK {
|
||||
return
|
||||
}
|
||||
fs.Debugf(src, "multi-thread copy: cancelling transfer on exit")
|
||||
fs.DebugfCtx(ctx, src, "multi-thread copy: cancelling transfer on exit")
|
||||
abortErr := chunkWriter.Abort(ctx)
|
||||
if abortErr != nil {
|
||||
fs.Debugf(src, "multi-thread copy: abort failed: %v", abortErr)
|
||||
fs.DebugfCtx(ctx, src, "multi-thread copy: abort failed: %v", abortErr)
|
||||
}
|
||||
})()
|
||||
|
||||
if info.ChunkSize > src.Size() {
|
||||
fs.Debugf(src, "multi-thread copy: chunk size %v was bigger than source file size %v", fs.SizeSuffix(info.ChunkSize), fs.SizeSuffix(src.Size()))
|
||||
fs.DebugfCtx(ctx, src, "multi-thread copy: chunk size %v was bigger than source file size %v", fs.SizeSuffix(info.ChunkSize), fs.SizeSuffix(src.Size()))
|
||||
info.ChunkSize = src.Size()
|
||||
}
|
||||
|
||||
// Use the backend concurrency if it is higher than --multi-thread-streams or if --multi-thread-streams wasn't set explicitly
|
||||
if !ci.MultiThreadSet || info.Concurrency > concurrency {
|
||||
fs.Debugf(src, "multi-thread copy: using backend concurrency of %d instead of --multi-thread-streams %d", info.Concurrency, concurrency)
|
||||
fs.DebugfCtx(ctx, src, "multi-thread copy: using backend concurrency of %d instead of --multi-thread-streams %d", info.Concurrency, concurrency)
|
||||
concurrency = info.Concurrency
|
||||
}
|
||||
|
||||
numChunks := calculateNumChunks(src.Size(), info.ChunkSize)
|
||||
if concurrency > numChunks {
|
||||
fs.Debugf(src, "multi-thread copy: number of streams %d was bigger than number of chunks %d", concurrency, numChunks)
|
||||
fs.DebugfCtx(ctx, src, "multi-thread copy: number of streams %d was bigger than number of chunks %d", concurrency, numChunks)
|
||||
concurrency = numChunks
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
|
||||
// Make accounting
|
||||
mc.acc = tr.Account(gCtx, nil)
|
||||
|
||||
fs.Debugf(src, "Starting multi-thread copy with %d chunks of size %v with %v parallel streams", mc.numChunks, fs.SizeSuffix(mc.partSize), concurrency)
|
||||
fs.DebugfCtx(ctx, src, "Starting multi-thread copy with %d chunks of size %v with %v parallel streams", mc.numChunks, fs.SizeSuffix(mc.partSize), concurrency)
|
||||
for chunk := range mc.numChunks {
|
||||
// Fail fast, in case an errgroup managed function returns an error
|
||||
if gCtx.Err() != nil {
|
||||
@@ -271,7 +271,7 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
|
||||
}
|
||||
setModTime = false
|
||||
} else {
|
||||
fs.Errorf(obj, "multi-thread copy: can't set metadata as SetMetadata isn't implemented in: %v", f)
|
||||
fs.ErrorfCtx(ctx, obj, "multi-thread copy: can't set metadata as SetMetadata isn't implemented in: %v", f)
|
||||
}
|
||||
}
|
||||
if setModTime {
|
||||
@@ -284,7 +284,7 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
|
||||
}
|
||||
}
|
||||
|
||||
fs.Debugf(src, "Finished multi-thread copy with %d parts of size %v", mc.numChunks, fs.SizeSuffix(mc.partSize))
|
||||
fs.DebugfCtx(ctx, src, "Finished multi-thread copy with %d parts of size %v", mc.numChunks, fs.SizeSuffix(mc.partSize))
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@ type writerAtChunkWriter struct {
|
||||
|
||||
// WriteChunk writes chunkNumber from reader
|
||||
func (w *writerAtChunkWriter) WriteChunk(ctx context.Context, chunkNumber int, reader io.ReadSeeker) (int64, error) {
|
||||
fs.Debugf(w.remote, "writing chunk %v", chunkNumber)
|
||||
fs.DebugfCtx(ctx, w.remote, "writing chunk %v", chunkNumber)
|
||||
|
||||
bytesToWrite := w.chunkSize
|
||||
if chunkNumber == (w.chunks-1) && w.size%w.chunkSize != 0 {
|
||||
@@ -344,7 +344,7 @@ func (w *writerAtChunkWriter) Close(ctx context.Context) error {
|
||||
func (w *writerAtChunkWriter) Abort(ctx context.Context) error {
|
||||
err := w.Close(ctx)
|
||||
if err != nil {
|
||||
fs.Errorf(w.remote, "multi-thread copy: failed to close file before aborting: %v", err)
|
||||
fs.ErrorfCtx(ctx, w.remote, "multi-thread copy: failed to close file before aborting: %v", err)
|
||||
}
|
||||
obj, err := w.f.NewObject(ctx, w.remote)
|
||||
if err != nil {
|
||||
@@ -364,7 +364,7 @@ func openChunkWriterFromOpenWriterAt(openWriterAt fs.OpenWriterAtFn, chunkSize i
|
||||
}
|
||||
|
||||
if writeBufferSize > 0 {
|
||||
fs.Debugf(src.Remote(), "multi-thread copy: write buffer set to %v", writeBufferSize)
|
||||
fs.DebugfCtx(ctx, src.Remote(), "multi-thread copy: write buffer set to %v", writeBufferSize)
|
||||
}
|
||||
|
||||
chunkWriter := &writerAtChunkWriter{
|
||||
|
||||
+101
-101
@@ -82,7 +82,7 @@ func checkHashes(ctx context.Context, src fs.ObjectInfo, dst fs.Object, ht hash.
|
||||
return srcErr
|
||||
}
|
||||
if srcHash == "" {
|
||||
fs.Debugf(src, "Src hash empty - aborting Dst hash check")
|
||||
fs.DebugfCtx(ctx, src, "Src hash empty - aborting Dst hash check")
|
||||
return errNoHash
|
||||
}
|
||||
return nil
|
||||
@@ -93,7 +93,7 @@ func checkHashes(ctx context.Context, src fs.ObjectInfo, dst fs.Object, ht hash.
|
||||
return dstErr
|
||||
}
|
||||
if dstHash == "" {
|
||||
fs.Debugf(dst, "Dst hash empty - aborting Src hash check")
|
||||
fs.DebugfCtx(ctx, dst, "Dst hash empty - aborting Src hash check")
|
||||
return errNoHash
|
||||
}
|
||||
return nil
|
||||
@@ -104,21 +104,21 @@ func checkHashes(ctx context.Context, src fs.ObjectInfo, dst fs.Object, ht hash.
|
||||
}
|
||||
if srcErr != nil {
|
||||
err = fs.CountError(ctx, srcErr)
|
||||
fs.Errorf(src, "Failed to calculate src hash: %v", err)
|
||||
fs.ErrorfCtx(ctx, src, "Failed to calculate src hash: %v", err)
|
||||
}
|
||||
if dstErr != nil {
|
||||
err = fs.CountError(ctx, dstErr)
|
||||
fs.Errorf(dst, "Failed to calculate dst hash: %v", err)
|
||||
fs.ErrorfCtx(ctx, dst, "Failed to calculate dst hash: %v", err)
|
||||
}
|
||||
if err != nil {
|
||||
return false, ht, srcHash, dstHash, err
|
||||
}
|
||||
if srcHash != dstHash {
|
||||
fs.Debugf(src, "%v = %s (%v)", ht, srcHash, src.Fs())
|
||||
fs.Debugf(dst, "%v = %s (%v)", ht, dstHash, dst.Fs())
|
||||
fs.DebugfCtx(ctx, src, "%v = %s (%v)", ht, srcHash, src.Fs())
|
||||
fs.DebugfCtx(ctx, dst, "%v = %s (%v)", ht, dstHash, dst.Fs())
|
||||
return false, ht, srcHash, dstHash, nil
|
||||
}
|
||||
fs.Debugf(src, "%v = %s OK", ht, srcHash)
|
||||
fs.DebugfCtx(ctx, src, "%v = %s OK", ht, srcHash)
|
||||
return true, ht, srcHash, dstHash, nil
|
||||
}
|
||||
|
||||
@@ -168,11 +168,11 @@ func DirsEqual(ctx context.Context, src, dst fs.Directory, opt DirsEqualOpt) (eq
|
||||
}
|
||||
dt := dstModTime.Sub(srcModTime)
|
||||
if dt < opt.ModifyWindow && dt > -opt.ModifyWindow {
|
||||
fs.Debugf(dst, "Directory modification time the same (differ by %s, within tolerance %s)", dt, opt.ModifyWindow)
|
||||
fs.DebugfCtx(ctx, dst, "Directory modification time the same (differ by %s, within tolerance %s)", dt, opt.ModifyWindow)
|
||||
return true
|
||||
}
|
||||
if ci.UpdateOlder && dt >= opt.ModifyWindow {
|
||||
fs.Debugf(dst, "Destination directory is newer than source, skipping")
|
||||
fs.DebugfCtx(ctx, dst, "Destination directory is newer than source, skipping")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -186,11 +186,11 @@ func sizeDiffers(ctx context.Context, src, dst fs.ObjectInfo) bool {
|
||||
return false
|
||||
}
|
||||
if src.Size() == dst.Size() {
|
||||
fs.Debugf(dst, "size = %d OK", dst.Size())
|
||||
fs.DebugfCtx(ctx, dst, "size = %d OK", dst.Size())
|
||||
return false
|
||||
}
|
||||
fs.Debugf(src, "size = %d (%v)", src.Size(), src.Fs())
|
||||
fs.Debugf(dst, "size = %d (%v)", dst.Size(), dst.Fs())
|
||||
fs.DebugfCtx(ctx, src, "size = %d (%v)", src.Size(), src.Fs())
|
||||
fs.DebugfCtx(ctx, dst, "size = %d (%v)", dst.Size(), dst.Fs())
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -248,12 +248,12 @@ func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt)
|
||||
ci := fs.GetConfig(ctx)
|
||||
logger, _ := GetLogger(ctx)
|
||||
if sizeDiffers(ctx, src, dst) {
|
||||
fs.Debug(src, "Sizes differ")
|
||||
fs.DebugfCtx(ctx, src, "Sizes differ")
|
||||
logger(ctx, Differ, src, dst, nil)
|
||||
return false
|
||||
}
|
||||
if opt.sizeOnly {
|
||||
fs.Debugf(src, "Sizes identical")
|
||||
fs.DebugfCtx(ctx, src, "Sizes identical")
|
||||
logger(ctx, Match, src, dst, nil)
|
||||
return true
|
||||
}
|
||||
@@ -265,7 +265,7 @@ func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt)
|
||||
// Check the hash
|
||||
same, ht, _ := CheckHashes(ctx, src, dst)
|
||||
if !same {
|
||||
fs.Debugf(src, "%v differ", ht)
|
||||
fs.DebugfCtx(ctx, src, "%v differ", ht)
|
||||
logger(ctx, Differ, src, dst, nil)
|
||||
return false
|
||||
}
|
||||
@@ -273,12 +273,12 @@ func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt)
|
||||
common := src.Fs().Hashes().Overlap(dst.Fs().Hashes())
|
||||
if common.Count() == 0 {
|
||||
checksumWarning.Do(func() {
|
||||
fs.Logf(dst.Fs(), "--checksum is in use but the source and destination have no hashes in common; falling back to --size-only")
|
||||
fs.LogfCtx(ctx, dst.Fs(), "--checksum is in use but the source and destination have no hashes in common; falling back to --size-only")
|
||||
})
|
||||
}
|
||||
fs.Debugf(src, "Size of src and dst objects identical")
|
||||
fs.DebugfCtx(ctx, src, "Size of src and dst objects identical")
|
||||
} else {
|
||||
fs.Debugf(src, "Size and %v of src and dst objects identical", ht)
|
||||
fs.DebugfCtx(ctx, src, "Size and %v of src and dst objects identical", ht)
|
||||
}
|
||||
logger(ctx, Match, src, dst, nil)
|
||||
return true
|
||||
@@ -289,25 +289,25 @@ func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt)
|
||||
// Sizes the same so check the mtime
|
||||
modifyWindow := fs.GetModifyWindow(ctx, src.Fs(), dst.Fs())
|
||||
if modifyWindow == fs.ModTimeNotSupported {
|
||||
fs.Debugf(src, "Sizes identical")
|
||||
fs.DebugfCtx(ctx, src, "Sizes identical")
|
||||
logger(ctx, Match, src, dst, nil)
|
||||
return true
|
||||
}
|
||||
dstModTime := dst.ModTime(ctx)
|
||||
dt := dstModTime.Sub(srcModTime)
|
||||
if dt < modifyWindow && dt > -modifyWindow {
|
||||
fs.Debugf(src, "Size and modification time the same (differ by %s, within tolerance %s)", dt, modifyWindow)
|
||||
fs.DebugfCtx(ctx, src, "Size and modification time the same (differ by %s, within tolerance %s)", dt, modifyWindow)
|
||||
logger(ctx, Match, src, dst, nil)
|
||||
return true
|
||||
}
|
||||
|
||||
fs.Debugf(src, "Modification times differ by %s: %v, %v", dt, srcModTime, dstModTime)
|
||||
fs.DebugfCtx(ctx, src, "Modification times differ by %s: %v, %v", dt, srcModTime, dstModTime)
|
||||
}
|
||||
|
||||
// Check if the hashes are the same
|
||||
same, ht, _ := CheckHashes(ctx, src, dst)
|
||||
if !same {
|
||||
fs.Debugf(src, "%v differ", ht)
|
||||
fs.DebugfCtx(ctx, src, "%v differ", ht)
|
||||
logger(ctx, Differ, src, dst, nil)
|
||||
return false
|
||||
}
|
||||
@@ -323,7 +323,7 @@ func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt)
|
||||
// Size and hash the same but mtime different
|
||||
// Error if objects are treated as immutable
|
||||
if ci.Immutable {
|
||||
fs.Errorf(dst, "Timestamp mismatch between immutable objects")
|
||||
fs.ErrorfCtx(ctx, dst, "Timestamp mismatch between immutable objects")
|
||||
logger(ctx, Differ, src, dst, nil)
|
||||
return false
|
||||
}
|
||||
@@ -331,27 +331,27 @@ func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt)
|
||||
err := dst.SetModTime(ctx, srcModTime)
|
||||
if errors.Is(err, fs.ErrorCantSetModTime) {
|
||||
logModTimeUpload(dst)
|
||||
fs.Infof(dst, "src and dst identical but can't set mod time without re-uploading")
|
||||
fs.InfofCtx(ctx, dst, "src and dst identical but can't set mod time without re-uploading")
|
||||
logger(ctx, Differ, src, dst, nil)
|
||||
return false
|
||||
} else if errors.Is(err, fs.ErrorCantSetModTimeWithoutDelete) {
|
||||
logModTimeUpload(dst)
|
||||
fs.Infof(dst, "src and dst identical but can't set mod time without deleting and re-uploading")
|
||||
fs.InfofCtx(ctx, dst, "src and dst identical but can't set mod time without deleting and re-uploading")
|
||||
// Remove the file if BackupDir isn't set. If BackupDir is set we would rather have the old file
|
||||
// put in the BackupDir than deleted which is what will happen if we don't delete it.
|
||||
if ci.BackupDir == "" {
|
||||
err = dst.Remove(ctx)
|
||||
if err != nil {
|
||||
fs.Errorf(dst, "failed to delete before re-upload: %v", err)
|
||||
fs.ErrorfCtx(ctx, dst, "failed to delete before re-upload: %v", err)
|
||||
}
|
||||
}
|
||||
logger(ctx, Differ, src, dst, nil)
|
||||
return false
|
||||
} else if err != nil {
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(dst, "Failed to set modification time: %v", err)
|
||||
fs.ErrorfCtx(ctx, dst, "Failed to set modification time: %v", err)
|
||||
} else {
|
||||
fs.Infof(src, "Updated modification time in destination")
|
||||
fs.InfofCtx(ctx, src, "Updated modification time in destination")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,19 +491,19 @@ func move(ctx context.Context, fdst fs.Fs, dst fs.Object, remote string, src fs.
|
||||
switch err {
|
||||
case nil:
|
||||
if newDst != nil && src.String() != newDst.String() {
|
||||
fs.Infof(src, "Moved (server-side) to: %s", newDst.String())
|
||||
fs.InfofCtx(ctx, src, "Moved (server-side) to: %s", newDst.String())
|
||||
} else {
|
||||
fs.Infof(src, "Moved (server-side)")
|
||||
fs.InfofCtx(ctx, src, "Moved (server-side)")
|
||||
}
|
||||
in.ServerSideMoveEnd(newDst.Size()) // account the bytes for the server-side transfer
|
||||
_ = in.Close()
|
||||
return newDst, nil
|
||||
case fs.ErrorCantMove:
|
||||
fs.Debugf(src, "Can't move, switching to copy")
|
||||
fs.DebugfCtx(ctx, src, "Can't move, switching to copy")
|
||||
_ = in.Close()
|
||||
default:
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(src, "Couldn't move: %v", err)
|
||||
fs.ErrorfCtx(ctx, src, "Couldn't move: %v", err)
|
||||
_ = in.Close()
|
||||
return newDst, err
|
||||
}
|
||||
@@ -514,7 +514,7 @@ func move(ctx context.Context, fdst fs.Fs, dst fs.Object, remote string, src fs.
|
||||
}
|
||||
newDst, err = Copy(ctx, fdst, dst, origRemote, src)
|
||||
if err != nil {
|
||||
fs.Errorf(src, "Not deleting source as copy failed: %v", err)
|
||||
fs.ErrorfCtx(ctx, src, "Not deleting source as copy failed: %v", err)
|
||||
return newDst, err
|
||||
}
|
||||
// Delete src if no error on copy
|
||||
@@ -576,10 +576,10 @@ func DeleteFileWithBackupDir(ctx context.Context, dst fs.Object, backupDir fs.Fs
|
||||
err = dst.Remove(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
fs.Errorf(dst, "Couldn't %s: %v", action, err)
|
||||
fs.ErrorfCtx(ctx, dst, "Couldn't %s: %v", action, err)
|
||||
err = fs.CountError(ctx, err)
|
||||
} else if !skip {
|
||||
fs.Infof(dst, "%s", actioned)
|
||||
fs.InfofCtx(ctx, dst, "%s", actioned)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -616,14 +616,14 @@ func DeleteFilesWithBackupDir(ctx context.Context, toBeDeleted fs.ObjectsChan, b
|
||||
logger, _ := GetLogger(ctx)
|
||||
logger(ctx, TransferError, nil, dst, err)
|
||||
if fserrors.IsFatalError(err) {
|
||||
fs.Errorf(dst, "Got fatal error on delete: %s", err)
|
||||
fs.ErrorfCtx(ctx, dst, "Got fatal error on delete: %s", err)
|
||||
fatalErrorCount.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
fs.Debugf(nil, "Waiting for deletions to finish")
|
||||
fs.DebugfCtx(ctx, nil, "Waiting for deletions to finish")
|
||||
wg.Wait()
|
||||
if errorCount.Load() > 0 {
|
||||
err := fmt.Errorf("failed to delete %d files", errorCount.Load())
|
||||
@@ -726,7 +726,7 @@ func filterCheck(ctx context.Context, f fs.Fs, dir string) bool {
|
||||
includeDirectory := fi.IncludeDirectory(ctx, f)
|
||||
include, err := includeDirectory(dir)
|
||||
if err != nil {
|
||||
fs.Errorf(f, "Failed to discover whether directory is included: %v", err)
|
||||
fs.ErrorfCtx(ctx, f, "Failed to discover whether directory is included: %v", err)
|
||||
return true
|
||||
}
|
||||
return include
|
||||
@@ -770,13 +770,13 @@ func Retry(ctx context.Context, o any, maxTries int, fn func() error) (err error
|
||||
}
|
||||
// Retry if err returned a retry error
|
||||
if fserrors.IsRetryError(err) || fserrors.ShouldRetry(err) {
|
||||
fs.Debugf(o, "Received error: %v - low level retry %d/%d", err, tries, maxTries)
|
||||
fs.DebugfCtx(ctx, o, "Received error: %v - low level retry %d/%d", err, tries, maxTries)
|
||||
continue
|
||||
} else if t, ok := pacer.IsRetryAfter(err); ok {
|
||||
if tries >= maxTries {
|
||||
break
|
||||
}
|
||||
fs.Debugf(o, "Sleeping for %v (as indicated by the server) to obey Retry-After error: %v", t, err)
|
||||
fs.DebugfCtx(ctx, o, "Sleeping for %v (as indicated by the server) to obey Retry-After error: %v", t, err)
|
||||
if !sleepWithContext(ctx, t) {
|
||||
fserrors.ContextError(ctx, &err)
|
||||
break
|
||||
@@ -1005,7 +1005,7 @@ func HashLister(ctx context.Context, ht hash.Type, outputBase64 bool, downloadFl
|
||||
}()
|
||||
sum, err := HashSum(ctx, ht, outputBase64, downloadFlag, o)
|
||||
if err != nil {
|
||||
fs.Errorf(o, "%v", fs.CountError(ctx, err))
|
||||
fs.ErrorfCtx(ctx, o, "%v", fs.CountError(ctx, err))
|
||||
return
|
||||
}
|
||||
SyncFprintf(w, "%*s %s\n", width, sum, o.Remote())
|
||||
@@ -1085,7 +1085,7 @@ func Mkdir(ctx context.Context, f fs.Fs, dir string) (err error) {
|
||||
if SkipDestructive(ctx, fs.LogDirName(f, dir), "make directory") {
|
||||
return nil
|
||||
}
|
||||
fs.Infof(fs.LogDirName(f, dir), "Making directory")
|
||||
fs.InfofCtx(ctx, fs.LogDirName(f, dir), "Making directory")
|
||||
err = f.Mkdir(ctx, dir)
|
||||
if err != nil {
|
||||
err = fs.CountError(ctx, err)
|
||||
@@ -1111,7 +1111,7 @@ func MkdirMetadata(ctx context.Context, f fs.Fs, dir string, metadata fs.Metadat
|
||||
if SkipDestructive(ctx, logName, "make directory") {
|
||||
return nil, nil
|
||||
}
|
||||
fs.Debugf(fs.LogDirName(f, dir), "Making directory with metadata")
|
||||
fs.DebugfCtx(ctx, fs.LogDirName(f, dir), "Making directory with metadata")
|
||||
newDst, err = do(ctx, dir, metadata)
|
||||
if err != nil {
|
||||
err = fs.CountError(ctx, err)
|
||||
@@ -1119,9 +1119,9 @@ func MkdirMetadata(ctx context.Context, f fs.Fs, dir string, metadata fs.Metadat
|
||||
}
|
||||
accounting.Stats(ctx).UpdatedDirs(1)
|
||||
if mtime, ok := metadata["mtime"]; ok {
|
||||
fs.Infof(logName, "Made directory with metadata (mtime=%s)", mtime)
|
||||
fs.InfofCtx(ctx, logName, "Made directory with metadata (mtime=%s)", mtime)
|
||||
} else {
|
||||
fs.Infof(logName, "Made directory with metadata")
|
||||
fs.InfofCtx(ctx, logName, "Made directory with metadata")
|
||||
}
|
||||
return newDst, err
|
||||
}
|
||||
@@ -1165,7 +1165,7 @@ func TryRmdir(ctx context.Context, f fs.Fs, dir string) error {
|
||||
if SkipDestructive(ctx, fs.LogDirName(f, dir), "remove directory") {
|
||||
return nil
|
||||
}
|
||||
fs.Infof(fs.LogDirName(f, dir), "Removing directory")
|
||||
fs.InfofCtx(ctx, fs.LogDirName(f, dir), "Removing directory")
|
||||
return f.Rmdir(ctx, dir)
|
||||
}
|
||||
|
||||
@@ -1257,23 +1257,23 @@ func RemoveExisting(ctx context.Context, f fs.Fs, remote string, operation strin
|
||||
remoteSaved = remote + suffix
|
||||
}
|
||||
|
||||
fs.Debugf(existingObj, "%s: renaming existing object to %q before starting", operation, remoteSaved)
|
||||
fs.DebugfCtx(ctx, existingObj, "%s: renaming existing object to %q before starting", operation, remoteSaved)
|
||||
existingObj, err = doMove(ctx, existingObj, remoteSaved)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: failed to rename existing file: %w", operation, err)
|
||||
}
|
||||
return func(perr *error) {
|
||||
if *perr == nil {
|
||||
fs.Debugf(existingObj, "%s: removing renamed existing file after operation", operation)
|
||||
fs.DebugfCtx(ctx, existingObj, "%s: removing renamed existing file after operation", operation)
|
||||
err := existingObj.Remove(ctx)
|
||||
if err != nil {
|
||||
*perr = fmt.Errorf("%s: failed to remove renamed existing file: %w", operation, err)
|
||||
}
|
||||
} else {
|
||||
fs.Debugf(existingObj, "%s: renaming existing back after failed operation", operation)
|
||||
fs.DebugfCtx(ctx, existingObj, "%s: renaming existing back after failed operation", operation)
|
||||
_, renameErr := doMove(ctx, existingObj, remote)
|
||||
if renameErr != nil {
|
||||
fs.Errorf(existingObj, "%s: failed to restore existing file after failed operation: %v", operation, renameErr)
|
||||
fs.ErrorfCtx(ctx, existingObj, "%s: failed to restore existing file after failed operation: %v", operation, renameErr)
|
||||
}
|
||||
}
|
||||
}, nil
|
||||
@@ -1299,7 +1299,7 @@ func listToChan(ctx context.Context, f fs.Fs, dir string) fs.ObjectsChan {
|
||||
if err != nil && err != fs.ErrorDirNotFound {
|
||||
err = fmt.Errorf("failed to list: %w", err)
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(nil, "%v", err)
|
||||
fs.ErrorfCtx(ctx, nil, "%v", err)
|
||||
}
|
||||
}()
|
||||
return o
|
||||
@@ -1338,13 +1338,13 @@ func Cat(ctx context.Context, f fs.Fs, w io.Writer, offset, count int64, sep []b
|
||||
defer mu.Unlock()
|
||||
err := catObject(ctx, o, w, offset, count)
|
||||
if err != nil {
|
||||
fs.Errorf(o, "Failed to send to output: %v", err)
|
||||
fs.ErrorfCtx(ctx, o, "Failed to send to output: %v", err)
|
||||
return
|
||||
}
|
||||
if len(sep) > 0 {
|
||||
if _, err := w.Write(sep); err != nil {
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(o, "Failed to send separator to output: %v", err)
|
||||
fs.ErrorfCtx(ctx, o, "Failed to send separator to output: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1466,10 +1466,10 @@ func rcatSrc(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadClos
|
||||
if fileIsSmall || doPutStream == nil {
|
||||
var rs io.ReadSeeker
|
||||
if fileIsSmall {
|
||||
fs.Debugf(fdst, "File to upload is small (%d bytes), uploading instead of streaming", len(buf))
|
||||
fs.DebugfCtx(ctx, fdst, "File to upload is small (%d bytes), uploading instead of streaming", len(buf))
|
||||
rs = bytes.NewReader(buf)
|
||||
} else {
|
||||
fs.Debugf(fdst, "Target remote doesn't support streaming uploads, creating temporary local FS to spool file")
|
||||
fs.DebugfCtx(ctx, fdst, "Target remote doesn't support streaming uploads, creating temporary local FS to spool file")
|
||||
spool, err := os.CreateTemp("", "rclone-spool")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temporary spool file: %v", err)
|
||||
@@ -1478,11 +1478,11 @@ func rcatSrc(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadClos
|
||||
defer func() {
|
||||
err := spool.Close()
|
||||
if err != nil {
|
||||
fs.Errorf(fileName, "Failed to close temporary spool file: %v", err)
|
||||
fs.ErrorfCtx(ctx, fileName, "Failed to close temporary spool file: %v", err)
|
||||
}
|
||||
err = os.Remove(fileName)
|
||||
if err != nil {
|
||||
fs.Errorf(fileName, "Failed to delete temporary spool file: %v", err)
|
||||
fs.ErrorfCtx(ctx, fileName, "Failed to delete temporary spool file: %v", err)
|
||||
}
|
||||
}()
|
||||
_, err = io.Copy(spool, streamIn)
|
||||
@@ -1523,7 +1523,7 @@ func rcatSrc(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadClos
|
||||
if !equal(ctx, src, dst, opt) {
|
||||
err = fmt.Errorf("corrupted on transfer")
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(dst, "%v", err)
|
||||
fs.ErrorfCtx(ctx, dst, "%v", err)
|
||||
return dst, err
|
||||
}
|
||||
return dst, nil
|
||||
@@ -1550,7 +1550,7 @@ func Rmdirs(ctx context.Context, f fs.Fs, dir string, leaveRoot bool) error {
|
||||
err := walk.Walk(ctx, f, dir, false, ci.MaxDepth, func(dirPath string, entries fs.DirEntries, err error) error {
|
||||
if err != nil {
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(f, "Failed to list %q: %v", dirPath, err)
|
||||
fs.ErrorfCtx(ctx, f, "Failed to list %q: %v", dirPath, err)
|
||||
return nil
|
||||
}
|
||||
for _, entry := range entries {
|
||||
@@ -1611,7 +1611,7 @@ func Rmdirs(ctx context.Context, f fs.Fs, dir string, leaveRoot bool) error {
|
||||
if len(dirs) == 0 {
|
||||
continue
|
||||
}
|
||||
fs.Debugf(nil, "removing %d level %d directories", len(dirs), level)
|
||||
fs.DebugfCtx(ctx, nil, "removing %d level %d directories", len(dirs), level)
|
||||
sort.Strings(dirs)
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(ci.Checkers)
|
||||
@@ -1625,7 +1625,7 @@ func Rmdirs(ctx context.Context, f fs.Fs, dir string, leaveRoot bool) error {
|
||||
err := TryRmdir(gCtx, f, dir)
|
||||
if err != nil {
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(dir, "Failed to rmdir: %v", err)
|
||||
fs.ErrorfCtx(gCtx, dir, "Failed to rmdir: %v", err)
|
||||
errCount.Add(err)
|
||||
}
|
||||
return nil // don't return errors, just count them
|
||||
@@ -1672,7 +1672,7 @@ func compareDest(ctx context.Context, dst, src fs.Object, CompareDest fs.Fs) (No
|
||||
opt := defaultEqualOpt(ctx)
|
||||
opt.updateModTime = false
|
||||
if equal(ctx, src, CompareDestFile, opt) {
|
||||
fs.Debugf(src, "Destination found in --compare-dest, skipping")
|
||||
fs.DebugfCtx(ctx, src, "Destination found in --compare-dest, skipping")
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
@@ -1731,16 +1731,16 @@ func copyDest(ctx context.Context, fdst fs.Fs, dst, src fs.Object, CopyDest, bac
|
||||
}
|
||||
_, err := Copy(ctx, fdst, dst, remote, CopyDestFile)
|
||||
if err != nil {
|
||||
fs.Errorf(src, "Destination found in --copy-dest, error copying")
|
||||
fs.ErrorfCtx(ctx, src, "Destination found in --copy-dest, error copying")
|
||||
return false, nil
|
||||
}
|
||||
fs.Debugf(src, "Destination found in --copy-dest, using server-side copy")
|
||||
fs.DebugfCtx(ctx, src, "Destination found in --copy-dest, using server-side copy")
|
||||
return true, nil
|
||||
}
|
||||
fs.Debugf(src, "Unchanged skipping")
|
||||
fs.DebugfCtx(ctx, src, "Unchanged skipping")
|
||||
return true, nil
|
||||
}
|
||||
fs.Debugf(src, "Destination not found in --copy-dest")
|
||||
fs.DebugfCtx(ctx, src, "Destination not found in --copy-dest")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -1777,19 +1777,19 @@ func NeedTransfer(ctx context.Context, dst, src fs.Object) bool {
|
||||
ci := fs.GetConfig(ctx)
|
||||
logger, _ := GetLogger(ctx)
|
||||
if dst == nil {
|
||||
fs.Debugf(src, "Need to transfer - File not found at Destination")
|
||||
fs.DebugfCtx(ctx, src, "Need to transfer - File not found at Destination")
|
||||
logger(ctx, MissingOnDst, src, nil, nil)
|
||||
return true
|
||||
}
|
||||
// If we should ignore existing files, don't transfer
|
||||
if ci.IgnoreExisting {
|
||||
fs.Debugf(src, "Destination exists, skipping")
|
||||
fs.DebugfCtx(ctx, src, "Destination exists, skipping")
|
||||
logger(ctx, Match, src, dst, nil)
|
||||
return false
|
||||
}
|
||||
// If we should upload unconditionally
|
||||
if ci.IgnoreTimes {
|
||||
fs.Debugf(src, "Transferring unconditionally as --ignore-times is in use")
|
||||
fs.DebugfCtx(ctx, src, "Transferring unconditionally as --ignore-times is in use")
|
||||
logger(ctx, Differ, src, dst, nil)
|
||||
return true
|
||||
}
|
||||
@@ -1808,7 +1808,7 @@ func NeedTransfer(ctx context.Context, dst, src fs.Object) bool {
|
||||
}
|
||||
switch {
|
||||
case dt >= modifyWindow:
|
||||
fs.Debugf(src, "Destination is newer than source, skipping")
|
||||
fs.DebugfCtx(ctx, src, "Destination is newer than source, skipping")
|
||||
logger(ctx, Match, src, dst, nil)
|
||||
return false
|
||||
case dt <= -modifyWindow:
|
||||
@@ -1816,7 +1816,7 @@ func NeedTransfer(ctx context.Context, dst, src fs.Object) bool {
|
||||
opt := defaultEqualOpt(ctx)
|
||||
opt.forceModTimeMatch = true
|
||||
if equal(ctx, src, dst, opt) {
|
||||
fs.Debugf(src, "Unchanged skipping")
|
||||
fs.DebugfCtx(ctx, src, "Unchanged skipping")
|
||||
return false
|
||||
}
|
||||
default:
|
||||
@@ -1824,10 +1824,10 @@ func NeedTransfer(ctx context.Context, dst, src fs.Object) bool {
|
||||
opt := defaultEqualOpt(ctx)
|
||||
opt.sizeOnly = !ci.CheckSum
|
||||
if equal(ctx, src, dst, opt) {
|
||||
fs.Debugf(src, "Destination mod time is within %v of source and files identical, skipping", modifyWindow)
|
||||
fs.DebugfCtx(ctx, src, "Destination mod time is within %v of source and files identical, skipping", modifyWindow)
|
||||
return false
|
||||
}
|
||||
fs.Debugf(src, "Destination mod time is within %v of source but files differ, transferring", modifyWindow)
|
||||
fs.DebugfCtx(ctx, src, "Destination mod time is within %v of source but files differ, transferring", modifyWindow)
|
||||
}
|
||||
} else {
|
||||
// Check to see if changed or not
|
||||
@@ -1836,7 +1836,7 @@ func NeedTransfer(ctx context.Context, dst, src fs.Object) bool {
|
||||
return !equalFn(ctx, src, dst)
|
||||
}
|
||||
if Equal(ctx, src, dst) && !SameObject(src, dst) {
|
||||
fs.Debugf(src, "Unchanged skipping")
|
||||
fs.DebugfCtx(ctx, src, "Unchanged skipping")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1883,7 +1883,7 @@ func RcatSize(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadClo
|
||||
info := object.NewStaticObjectInfo(dstFileName, modTime, size, true, nil, fdst).WithMetadata(meta)
|
||||
obj, err = fdst.Put(ctx, in, info, options...)
|
||||
if err != nil {
|
||||
fs.Errorf(dstFileName, "Post request put error: %v", err)
|
||||
fs.ErrorfCtx(ctx, dstFileName, "Post request put error: %v", err)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
@@ -1901,10 +1901,10 @@ func RcatSize(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadClo
|
||||
}
|
||||
if err != nil {
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(obj, "%v", err)
|
||||
fs.Infof(obj, "Removing failed copy")
|
||||
fs.ErrorfCtx(ctx, obj, "%v", err)
|
||||
fs.InfofCtx(ctx, obj, "Removing failed copy")
|
||||
if removeErr := obj.Remove(ctx); removeErr != nil {
|
||||
fs.Infof(obj, "Failed to remove failed copy: %s", removeErr)
|
||||
fs.InfofCtx(ctx, obj, "Failed to remove failed copy: %s", removeErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -1912,7 +1912,7 @@ func RcatSize(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadClo
|
||||
// Size unknown use Rcat
|
||||
obj, err = Rcat(ctx, fdst, dstFileName, in, modTime, meta)
|
||||
if err != nil {
|
||||
fs.Errorf(dstFileName, "Post request rcat error: %v", err)
|
||||
fs.ErrorfCtx(ctx, dstFileName, "Post request rcat error: %v", err)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
@@ -1953,7 +1953,7 @@ func copyURLFn(ctx context.Context, dstFileName string, url string, autoFilename
|
||||
if err != nil || headerFilename == "" {
|
||||
return fmt.Errorf("CopyURL failed: filename not found in the Content-Disposition header")
|
||||
}
|
||||
fs.Debugf(headerFilename, "filename found in Content-Disposition header.")
|
||||
fs.DebugfCtx(ctx, headerFilename, "filename found in Content-Disposition header.")
|
||||
return fn(ctx, headerFilename, resp.Body, resp.ContentLength, modTime)
|
||||
}
|
||||
|
||||
@@ -1961,7 +1961,7 @@ func copyURLFn(ctx context.Context, dstFileName string, url string, autoFilename
|
||||
if dstFileName == "." || dstFileName == "/" {
|
||||
return fmt.Errorf("CopyURL failed: file name wasn't found in url")
|
||||
}
|
||||
fs.Debugf(dstFileName, "File name found in url")
|
||||
fs.DebugfCtx(ctx, dstFileName, "File name found in url")
|
||||
}
|
||||
return fn(ctx, dstFileName, resp.Body, resp.ContentLength, modTime)
|
||||
}
|
||||
@@ -2075,13 +2075,13 @@ func MoveCaseInsensitive(ctx context.Context, fdst fs.Fs, fsrc fs.Fs, dstFileNam
|
||||
logger(ctx, TransferError, nil, tmpObjFail, err)
|
||||
return nil, fmt.Errorf("error while attempting to move file to a temporary location: %w", err)
|
||||
}
|
||||
fs.Debugf(srcObj, "moving to %v", tmpObjName)
|
||||
fs.DebugfCtx(ctx, srcObj, "moving to %v", tmpObjName)
|
||||
tmpObj, err := Op(ctx, fdst, nil, tmpObjName, srcObj)
|
||||
if err != nil {
|
||||
logger(ctx, TransferError, srcObj, tmpObj, err)
|
||||
return nil, fmt.Errorf("error while moving file to temporary location: %w", err)
|
||||
}
|
||||
fs.Debugf(srcObj, "moving to %v", dstFileName)
|
||||
fs.DebugfCtx(ctx, srcObj, "moving to %v", dstFileName)
|
||||
newDst, err = Op(ctx, fdst, nil, dstFileName, tmpObj)
|
||||
logger(ctx, MissingOnDst, tmpObj, nil, err)
|
||||
return newDst, err
|
||||
@@ -2094,7 +2094,7 @@ func moveOrCopyFile(ctx context.Context, fdst fs.Fs, fsrc fs.Fs, dstFileName str
|
||||
dstFilePath := path.Join(fdst.Root(), dstFileName)
|
||||
srcFilePath := path.Join(fsrc.Root(), srcFileName)
|
||||
if fdst.Name() == fsrc.Name() && dstFilePath == srcFilePath && !allowOverlap {
|
||||
fs.Debugf(fdst, "don't need to copy/move %s, it is already at target location", dstFileName)
|
||||
fs.DebugfCtx(ctx, fdst, "don't need to copy/move %s, it is already at target location", dstFileName)
|
||||
if usingLogger {
|
||||
srcObj, _ := fsrc.NewObject(ctx, srcFileName)
|
||||
dstObj, _ := fsrc.NewObject(ctx, dstFileName)
|
||||
@@ -2186,7 +2186,7 @@ func moveOrCopyFile(ctx context.Context, fdst fs.Fs, fsrc fs.Fs, dstFileName str
|
||||
_, err = Op(ctx, fdst, dstObj, dstFileName, srcObj)
|
||||
} else if !cp {
|
||||
if ci.IgnoreExisting {
|
||||
fs.Debugf(srcObj, "Not removing source file as destination file exists and --ignore-existing is set")
|
||||
fs.DebugfCtx(ctx, srcObj, "Not removing source file as destination file exists and --ignore-existing is set")
|
||||
logger(ctx, Match, srcObj, dstObj, nil)
|
||||
} else if !SameObject(srcObj, dstObj) {
|
||||
err = DeleteFile(ctx, srcObj)
|
||||
@@ -2215,12 +2215,12 @@ func SetTier(ctx context.Context, fsrc fs.Fs, tier string) error {
|
||||
return ListFn(ctx, fsrc, func(o fs.Object) {
|
||||
objImpl, ok := o.(fs.SetTierer)
|
||||
if !ok {
|
||||
fs.Errorf(fsrc, "Remote object does not implement SetTier")
|
||||
fs.ErrorfCtx(ctx, fsrc, "Remote object does not implement SetTier")
|
||||
return
|
||||
}
|
||||
err := objImpl.SetTier(tier)
|
||||
if err != nil {
|
||||
fs.Errorf(fsrc, "Failed to do SetTier, %v", err)
|
||||
fs.ErrorfCtx(ctx, fsrc, "Failed to do SetTier, %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2233,7 +2233,7 @@ func SetTierFile(ctx context.Context, o fs.Object, tier string) error {
|
||||
}
|
||||
err := do.SetTier(tier)
|
||||
if err != nil {
|
||||
fs.Errorf(o, "Failed to do SetTier, %v", err)
|
||||
fs.ErrorfCtx(ctx, o, "Failed to do SetTier, %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -2248,12 +2248,12 @@ func TouchDir(ctx context.Context, f fs.Fs, remote string, t time.Time, recursiv
|
||||
entries.ForObject(func(o fs.Object) {
|
||||
if !SkipDestructive(ctx, o, "touch") {
|
||||
g.Go(func() error {
|
||||
fs.Debugf(f, "Touching %q", o.Remote())
|
||||
fs.DebugfCtx(ctx, f, "Touching %q", o.Remote())
|
||||
err := o.SetModTime(gCtx, t)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to touch: %w", err)
|
||||
err = fs.CountError(gCtx, err)
|
||||
fs.Errorf(o, "%v", err)
|
||||
fs.ErrorfCtx(ctx, o, "%v", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -2487,7 +2487,7 @@ func DirMove(ctx context.Context, f fs.Fs, srcRemote, dstRemote string) (err err
|
||||
if err != fs.ErrorCantDirMove && err != fs.ErrorDirExists {
|
||||
return err
|
||||
}
|
||||
fs.Infof(f, "Can't DirMove - falling back to file moves: %v", err)
|
||||
fs.InfofCtx(ctx, f, "Can't DirMove - falling back to file moves: %v", err)
|
||||
}
|
||||
|
||||
// Load the directory tree into memory
|
||||
@@ -2653,18 +2653,18 @@ func skipDestructiveChoose(ctx context.Context, subject any, action string) (ski
|
||||
case 's':
|
||||
skip = true
|
||||
skipped[action] = true
|
||||
fs.Logf(nil, "Skipping all %s operations from now on without asking", action)
|
||||
fs.LogfCtx(ctx, nil, "Skipping all %s operations from now on without asking", action)
|
||||
case '!':
|
||||
skip = false
|
||||
skipped[action] = false
|
||||
fs.Logf(nil, "Doing all %s operations from now on without asking", action)
|
||||
fs.LogfCtx(ctx, nil, "Doing all %s operations from now on without asking", action)
|
||||
case 'q':
|
||||
fs.Logf(nil, "Quitting rclone now")
|
||||
fs.LogfCtx(ctx, nil, "Quitting rclone now")
|
||||
atexit.Run()
|
||||
os.Exit(0)
|
||||
default:
|
||||
skip = true
|
||||
fs.Errorf(nil, "Bad choice %c", i)
|
||||
fs.ErrorfCtx(ctx, nil, "Bad choice %c", i)
|
||||
}
|
||||
return skip
|
||||
}
|
||||
@@ -2704,9 +2704,9 @@ func SkipDestructive(ctx context.Context, subject any, action string) (skip bool
|
||||
size = do.Size()
|
||||
}
|
||||
if size >= 0 {
|
||||
fs.Logf(subject, "Skipped %s as %s is set (size %v)", fs.LogValue("skipped", action), flag, fs.LogValue("size", fs.SizeSuffix(size)))
|
||||
fs.LogfCtx(ctx, subject, "Skipped %s as %s is set (size %v)", fs.LogValue("skipped", action), flag, fs.LogValue("size", fs.SizeSuffix(size)))
|
||||
} else {
|
||||
fs.Logf(subject, "Skipped %s as %s is set", fs.LogValue("skipped", action), flag)
|
||||
fs.LogfCtx(ctx, subject, "Skipped %s as %s is set", fs.LogValue("skipped", action), flag)
|
||||
}
|
||||
}
|
||||
return skip
|
||||
@@ -2794,7 +2794,7 @@ func CopyDirMetadata(ctx context.Context, f fs.Fs, dst fs.Directory, dir string,
|
||||
return nil, err
|
||||
}
|
||||
accounting.Stats(ctx).UpdatedDirs(1)
|
||||
fs.Infof(logName, "Updated directory metadata")
|
||||
fs.InfofCtx(ctx, logName, "Updated directory metadata")
|
||||
return newDst, nil
|
||||
}
|
||||
|
||||
@@ -2810,7 +2810,7 @@ func SetDirModTime(ctx context.Context, f fs.Fs, dst fs.Directory, dir string, m
|
||||
logName := dirName(f, dst, dir)
|
||||
ci := fs.GetConfig(ctx)
|
||||
if ci.NoUpdateDirModTime {
|
||||
fs.Debugf(logName, "Skipping set directory modification time as --no-update-dir-modtime is set")
|
||||
fs.DebugfCtx(ctx, logName, "Skipping set directory modification time as --no-update-dir-modtime is set")
|
||||
return nil, nil
|
||||
}
|
||||
tr := accounting.Stats(ctx).NewCheckingTransferNoHistory(dirTransferEntry(f, dst, dir), "setting modtime")
|
||||
@@ -2837,7 +2837,7 @@ func SetDirModTime(ctx context.Context, f fs.Fs, dst fs.Directory, dir string, m
|
||||
return dst, err
|
||||
} else {
|
||||
accounting.Stats(ctx).UpdatedDirs(1)
|
||||
fs.Infof(logName, "Set directory modification time (using SetModTime)")
|
||||
fs.InfofCtx(ctx, logName, "Set directory modification time (using SetModTime)")
|
||||
return dst, nil
|
||||
}
|
||||
}
|
||||
@@ -2850,7 +2850,7 @@ func SetDirModTime(ctx context.Context, f fs.Fs, dst fs.Directory, dir string, m
|
||||
return dst, err
|
||||
}
|
||||
accounting.Stats(ctx).UpdatedDirs(1)
|
||||
fs.Infof(logName, "Set directory modification time (using DirSetModTime)")
|
||||
fs.InfofCtx(ctx, logName, "Set directory modification time (using DirSetModTime)")
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -314,7 +314,7 @@ func rcSingleCommand(ctx context.Context, in rc.Params, name string, noRemote bo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fs.Debugf(obj, "Upload Succeeded")
|
||||
fs.DebugfCtx(ctx, obj, "Upload Succeeded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ func (h *ReOpen) open() error {
|
||||
}
|
||||
if h.err != nil {
|
||||
if h.tries > 1 {
|
||||
fs.Debugf(h.src, "Reopen failed after offset %d bytes read: %v", h.offset, h.err)
|
||||
fs.DebugfCtx(h.ctx, h.src, "Reopen failed after offset %d bytes read: %v", h.offset, h.err)
|
||||
}
|
||||
return h.err
|
||||
}
|
||||
@@ -194,7 +194,7 @@ func (h *ReOpen) Read(p []byte) (n int, err error) {
|
||||
// re-open if seek needed
|
||||
if h.newOffset >= 0 {
|
||||
if h.offset != h.newOffset {
|
||||
fs.Debugf(h.src, "Seek from %d to %d", h.offset, h.newOffset)
|
||||
fs.DebugfCtx(h.ctx, h.src, "Seek from %d to %d", h.offset, h.newOffset)
|
||||
h.offset = h.newOffset
|
||||
err = h.reopen()
|
||||
if err != nil {
|
||||
@@ -214,7 +214,7 @@ func (h *ReOpen) Read(p []byte) (n int, err error) {
|
||||
if err != nil && err != io.EOF {
|
||||
h.err = err
|
||||
if !fserrors.IsNoLowLevelRetryError(err) {
|
||||
fs.Debugf(h.src, "Reopening on read failure after offset %d bytes: retry %d/%d: %v", h.offset, h.tries, h.maxTries, err)
|
||||
fs.DebugfCtx(h.ctx, h.src, "Reopening on read failure after offset %d bytes: retry %d/%d: %v", h.offset, h.tries, h.maxTries, err)
|
||||
if h.reopen() == nil {
|
||||
err = nil
|
||||
}
|
||||
|
||||
+176
-5
@@ -7,6 +7,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/accounting"
|
||||
"github.com/rclone/rclone/fs/cache"
|
||||
"github.com/rclone/rclone/fs/log"
|
||||
"github.com/rclone/rclone/fs/rc"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
@@ -27,6 +30,9 @@ import (
|
||||
func init() {
|
||||
cache.JobOnFinish = OnFinish
|
||||
cache.JobGetJobID = GetJobID
|
||||
|
||||
// Attribute logs to the rc job which made them
|
||||
fs.JobIDFromContext = GetJobID
|
||||
}
|
||||
|
||||
// Job describes an asynchronous task started via the rc package
|
||||
@@ -49,12 +55,52 @@ type Job struct {
|
||||
// the real error to the upper application layers while still printing the
|
||||
// string error message.
|
||||
realErr error
|
||||
|
||||
logsOpt logsOpt // how the logs were asked for with _logs
|
||||
logStart int64 // sequence number in log.Recent when the job started
|
||||
logEnd int64 // sequence number in log.Recent when the job finished
|
||||
}
|
||||
|
||||
// logs returns the logs made while the job was running so far or nil
|
||||
// if they weren't asked for with _logs.
|
||||
//
|
||||
// It returns the logs from sequence number since, or from the start
|
||||
// of the job if since is before that, and at most limit entries if
|
||||
// limit > 0.
|
||||
//
|
||||
// This returns the logs attributed to the job and the logs which
|
||||
// aren't attributed to anything. As not all logs are attributed, the
|
||||
// latter may include logs from anything else which was running at the
|
||||
// same time.
|
||||
//
|
||||
// Call with job.mu held.
|
||||
func (job *Job) logs(since int64, limit int) rc.Params {
|
||||
if !job.logsOpt.wanted {
|
||||
return nil
|
||||
}
|
||||
// Never read from before the job started, so since 0 means
|
||||
// the start of the job and lost counts what the job made.
|
||||
since = max(since, job.logStart)
|
||||
logEnd := int64(math.MaxInt64)
|
||||
if job.Finished {
|
||||
logEnd = job.logEnd
|
||||
}
|
||||
// Return the job's own logs, and any which aren't attributed
|
||||
// unless they were turned off with _logs unattributed
|
||||
filter := log.Filter{Level: job.logsOpt.level, JobID: job.ID, Unattributed: job.logsOpt.unattributed}
|
||||
entries, next, lost := log.Recent.Get(since, logEnd, filter, limit)
|
||||
return rc.Params{
|
||||
"entries": entries,
|
||||
"next": next,
|
||||
"lost": lost,
|
||||
}
|
||||
}
|
||||
|
||||
// mark the job as finished
|
||||
func (job *Job) finish(out rc.Params, err error) {
|
||||
job.mu.Lock()
|
||||
job.EndTime = time.Now()
|
||||
job.logEnd = log.Recent.Seq()
|
||||
if out == nil {
|
||||
out = make(rc.Params)
|
||||
}
|
||||
@@ -108,11 +154,14 @@ func (job *Job) OnFinish(fn func()) func() {
|
||||
func (job *Job) run(ctx context.Context, fn rc.Func, in rc.Params) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
stack := debug.Stack()
|
||||
// Finish the job before logging the stack trace so the
|
||||
// log is outside the window returned by _logs.
|
||||
job.finish(nil, fmt.Errorf("panic received: %v", r))
|
||||
// Log the full stack trace server-side only - it must not
|
||||
// be returned to the rc caller as it leaks internal paths,
|
||||
// dependency versions and memory addresses.
|
||||
fs.Errorf(nil, "rc: job %d panic: %v\n%s", job.ID, r, string(debug.Stack()))
|
||||
job.finish(nil, fmt.Errorf("panic received: %v", r))
|
||||
fs.Errorf(nil, "rc: job %d panic: %v\n%s", job.ID, r, string(stack))
|
||||
}
|
||||
}()
|
||||
job.finish(fn(ctx, in))
|
||||
@@ -250,8 +299,79 @@ type jobKeyType struct{}
|
||||
// Key for adding jobs to ctx
|
||||
var jobKey = jobKeyType{}
|
||||
|
||||
// LogsRequested returns true if in has _logs set to anything other
|
||||
// than false.
|
||||
//
|
||||
// This doesn't check the value of _logs is valid.
|
||||
func LogsRequested(in rc.Params) bool {
|
||||
wantLogs, err := in.GetBool("_logs")
|
||||
if rc.IsErrParamNotFound(err) {
|
||||
return false
|
||||
}
|
||||
return err != nil || wantLogs
|
||||
}
|
||||
|
||||
// logsOpt is how the logs of a job were asked for with _logs.
|
||||
type logsOpt struct {
|
||||
wanted bool
|
||||
level slog.Level
|
||||
unattributed bool // return the logs which aren't attributed to a job too
|
||||
}
|
||||
|
||||
// logsOptJSON is the object form of the _logs parameter.
|
||||
type logsOptJSON struct {
|
||||
Level string `json:"level"`
|
||||
Unattributed *bool `json:"unattributed"`
|
||||
}
|
||||
|
||||
// See if _logs is set returning how the logs were asked for.
|
||||
//
|
||||
// _logs can be a boolean, a log level, e.g. "INFO", or an object,
|
||||
// e.g. {"level": "INFO", "unattributed": false}.
|
||||
func getLogs(in rc.Params) (opt logsOpt, err error) {
|
||||
opt = logsOpt{
|
||||
wanted: LogsRequested(in),
|
||||
level: slog.LevelDebug,
|
||||
unattributed: true,
|
||||
}
|
||||
_, boolErr := in.GetBool("_logs")
|
||||
levelString, levelErr := in.GetString("_logs")
|
||||
var optJSON logsOptJSON
|
||||
structErr := in.GetStruct("_logs", &optJSON)
|
||||
delete(in, "_logs") // remove the logs parameter after parsing
|
||||
if !opt.wanted {
|
||||
return logsOpt{}, nil
|
||||
}
|
||||
if !log.Recent.Enabled() {
|
||||
return logsOpt{}, log.ErrBufferDisabled
|
||||
}
|
||||
// _logs is true, a level or an object here
|
||||
if boolErr != nil {
|
||||
if structErr == nil && (optJSON.Level != "" || optJSON.Unattributed != nil) {
|
||||
levelString = optJSON.Level
|
||||
if optJSON.Unattributed != nil {
|
||||
opt.unattributed = *optJSON.Unattributed
|
||||
}
|
||||
if levelString == "" {
|
||||
return opt, nil
|
||||
}
|
||||
} else if levelErr != nil {
|
||||
return logsOpt{}, levelErr
|
||||
}
|
||||
opt.level, err = log.ParseLevel(levelString)
|
||||
if err != nil {
|
||||
return logsOpt{}, rc.NewErrParamInvalid(fmt.Errorf("_logs must be a boolean, a log level or an object: %w", err))
|
||||
}
|
||||
if opt.level >= fs.SlogLevelOff {
|
||||
return logsOpt{}, rc.NewErrParamInvalid(fmt.Errorf("_logs level can't be %q", levelString))
|
||||
}
|
||||
}
|
||||
return opt, nil
|
||||
}
|
||||
|
||||
// NewJob creates a Job and executes it, possibly in the background if _async is set
|
||||
func (jobs *Jobs) NewJob(ctx context.Context, fn rc.Func, in rc.Params) (job *Job, out rc.Params, err error) {
|
||||
logStart := log.Recent.Seq() // before anything is logged for this job
|
||||
id := jobID.Add(1)
|
||||
in = in.Copy() // copy input so we can change it
|
||||
|
||||
@@ -275,6 +395,11 @@ func (jobs *Jobs) NewJob(ctx context.Context, fn rc.Func, in rc.Params) (job *Jo
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
logsOpt, err := getLogs(in)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
stop := func() {
|
||||
cancel()
|
||||
@@ -287,6 +412,8 @@ func (jobs *Jobs) NewJob(ctx context.Context, fn rc.Func, in rc.Params) (job *Jo
|
||||
Group: group,
|
||||
StartTime: time.Now(),
|
||||
Stop: stop,
|
||||
logsOpt: logsOpt,
|
||||
logStart: logStart,
|
||||
}
|
||||
|
||||
jobs.mu.Lock()
|
||||
@@ -309,6 +436,18 @@ func (jobs *Jobs) NewJob(ctx context.Context, fn rc.Func, in rc.Params) (job *Jo
|
||||
job.run(ctx, fn, in)
|
||||
out = job.Output
|
||||
err = job.realErr
|
||||
if logsOpt.wanted {
|
||||
job.mu.Lock()
|
||||
logs := job.logs(-1, 0)
|
||||
job.mu.Unlock()
|
||||
if err != nil {
|
||||
err = rc.NewErrorWithLogs(err, logs)
|
||||
} else {
|
||||
// Don't add the logs to job.Output
|
||||
out = out.Copy()
|
||||
out["_logs"] = logs
|
||||
}
|
||||
}
|
||||
}
|
||||
return job, out, err
|
||||
}
|
||||
@@ -352,6 +491,10 @@ func init() {
|
||||
Help: `Parameters:
|
||||
|
||||
- jobid - id of the job (integer).
|
||||
- since - return logs with sequence numbers starting from this - optional
|
||||
- Defaults to the start of the job, and is clamped to it, so the
|
||||
logs made before the job started are never returned.
|
||||
- limit - maximum number of log entries to return - optional, defaults to 1000, 0 for no limit
|
||||
|
||||
Results:
|
||||
|
||||
@@ -365,6 +508,12 @@ Results:
|
||||
- startTime - time the job started (e.g. "2018-10-26T18:50:20.528336039+01:00")
|
||||
- success - boolean - true for success false otherwise
|
||||
- output - output of the job as would have been returned if called synchronously
|
||||
- _logs - the logs of the job if it was started with _logs
|
||||
- entries - array of log entries, oldest first - the job's own logs, and
|
||||
the logs made while it was running which aren't attributed to a job
|
||||
unless they were turned off with the _logs unattributed parameter
|
||||
- next - pass this as since to carry on reading the logs from where this call finished
|
||||
- lost - the number of log entries which had already been dropped from the log buffer
|
||||
- progress - output of the progress related to the underlying job
|
||||
`,
|
||||
})
|
||||
@@ -387,6 +536,28 @@ func rcJobStatus(ctx context.Context, in rc.Params) (out rc.Params, err error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reshape failed in job status: %w", err)
|
||||
}
|
||||
if job.logsOpt.wanted {
|
||||
since, err := in.GetInt64("since")
|
||||
if rc.IsErrParamNotFound(err) {
|
||||
since = -1
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit, err := in.GetInt64("limit")
|
||||
if rc.IsErrParamNotFound(err) {
|
||||
limit = 1000
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Put the logs in the output so it is the same as would
|
||||
// have been returned if called synchronously
|
||||
output, ok := out["output"].(map[string]any)
|
||||
if !ok {
|
||||
output = map[string]any{}
|
||||
}
|
||||
output["_logs"] = job.logs(since, int(min(limit, math.MaxInt32)))
|
||||
out["output"] = output
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -518,7 +689,7 @@ func NewJobFromParams(ctx context.Context, in rc.Params) (out rc.Params) {
|
||||
}
|
||||
}
|
||||
|
||||
fs.Debugf(nil, "rc: %q: with parameters %+v", path, in)
|
||||
rc.LogCall("rc: %q: with parameters %+v", path, in)
|
||||
_, out, err = NewJob(ctx, call.Fn, in)
|
||||
if err != nil {
|
||||
return rcError(err, http.StatusInternalServerError)
|
||||
@@ -527,7 +698,7 @@ func NewJobFromParams(ctx context.Context, in rc.Params) (out rc.Params) {
|
||||
out = make(rc.Params)
|
||||
}
|
||||
|
||||
fs.Debugf(nil, "rc: %q: reply %+v: %v", path, out, err)
|
||||
rc.LogCall("rc: %q: reply %+v: %v", path, out, err)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -576,7 +747,7 @@ This takes the following parameters:
|
||||
}
|
||||
|||
|
||||
|
||||
The inputs may use |_async|, |_group|, |_config| and |_filter| as normal when using the rc.
|
||||
The inputs may use |_async|, |_group|, |_config|, |_filter| and |_logs| as normal when using the rc.
|
||||
|
||||
Returns:
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/accounting"
|
||||
"github.com/rclone/rclone/fs/filter"
|
||||
"github.com/rclone/rclone/fs/log"
|
||||
"github.com/rclone/rclone/fs/rc"
|
||||
"github.com/rclone/rclone/fstest/testy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -1089,3 +1092,330 @@ func TestJobsBatchConcurrent(t *testing.T) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// setLogBuffer sets the size of the log buffer for the duration of the test.
|
||||
func setLogBuffer(t *testing.T, size fs.SizeSuffix) {
|
||||
setSize := func(size fs.SizeSuffix) {
|
||||
call := rc.Calls.Get("options/set")
|
||||
require.NotNil(t, call)
|
||||
_, err := call.Fn(context.Background(), rc.Params{"log": rc.Params{"BufferSize": size.String()}})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
oldSize := log.Opt.BufferSize
|
||||
setSize(size)
|
||||
t.Cleanup(func() { setSize(oldSize) })
|
||||
}
|
||||
|
||||
// logMessages returns the msg fields of the log entries in logs.
|
||||
func logMessages(t *testing.T, logs any) (msgs []string) {
|
||||
t.Helper()
|
||||
require.NotNil(t, logs)
|
||||
// Reshape so this works on direct output and output via JSON
|
||||
var decoded struct {
|
||||
Entries []struct {
|
||||
Msg string `json:"msg"`
|
||||
} `json:"entries"`
|
||||
Next *int64 `json:"next"`
|
||||
Lost *int64 `json:"lost"`
|
||||
}
|
||||
require.NoError(t, rc.Reshape(&decoded, logs))
|
||||
require.NotNil(t, decoded.Lost)
|
||||
assert.Equal(t, int64(0), *decoded.Lost)
|
||||
require.NotNil(t, decoded.Next)
|
||||
for _, entry := range decoded.Entries {
|
||||
msgs = append(msgs, entry.Msg)
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
func TestExecuteJobWithLogs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
jobID.Store(0)
|
||||
var got rc.Params
|
||||
jobFn := func(ctx context.Context, in rc.Params) (rc.Params, error) {
|
||||
got = in
|
||||
fs.Logf(nil, "notice from job")
|
||||
fs.Errorf(nil, "error from job")
|
||||
return rc.Params{"result": "OK"}, nil
|
||||
}
|
||||
|
||||
// Fails if the log buffer isn't enabled
|
||||
setLogBuffer(t, 0)
|
||||
_, _, err := NewJob(ctx, jobFn, rc.Params{"_logs": true})
|
||||
assert.Equal(t, log.ErrBufferDisabled, err)
|
||||
assert.True(t, rc.IsErrParamInvalid(err), err)
|
||||
|
||||
setLogBuffer(t, 1*fs.Mebi)
|
||||
fs.Logf(nil, "before job")
|
||||
|
||||
// No logs unless asked for
|
||||
_, out, err := NewJob(ctx, jobFn, rc.Params{})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, out, "_logs")
|
||||
_, out, err = NewJob(ctx, jobFn, rc.Params{"_logs": false})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, out, "_logs")
|
||||
|
||||
// All the logs
|
||||
job, out, err := NewJob(ctx, jobFn, rc.Params{"_logs": true})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "OK", out["result"])
|
||||
assert.Equal(t, []string{"notice from job", "error from job"}, logMessages(t, out["_logs"]))
|
||||
assert.NotContains(t, got, "_logs", "_logs should have been removed from in")
|
||||
assert.NotContains(t, job.Output, "_logs", "_logs should not be in the job output")
|
||||
|
||||
// As passed by rclone rc _logs=true
|
||||
_, out, err = NewJob(ctx, jobFn, rc.Params{"_logs": "true"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"notice from job", "error from job"}, logMessages(t, out["_logs"]))
|
||||
|
||||
// Logs filtered by level
|
||||
_, out, err = NewJob(ctx, jobFn, rc.Params{"_logs": "ERROR"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"error from job"}, logMessages(t, out["_logs"]))
|
||||
|
||||
// Bad level
|
||||
_, _, err = NewJob(ctx, jobFn, rc.Params{"_logs": "potato"})
|
||||
require.Error(t, err)
|
||||
assert.True(t, rc.IsErrParamInvalid(err), err)
|
||||
|
||||
// OFF isn't a valid level for _logs
|
||||
_, _, err = NewJob(ctx, jobFn, rc.Params{"_logs": "OFF"})
|
||||
require.Error(t, err)
|
||||
assert.True(t, rc.IsErrParamInvalid(err), err)
|
||||
|
||||
// The object form gives control over what is returned
|
||||
_, out, err = NewJob(ctx, jobFn, rc.Params{"_logs": rc.Params{"level": "ERROR"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"error from job"}, logMessages(t, out["_logs"]))
|
||||
|
||||
// Unattributed logs can be left out
|
||||
unattributedFn := func(ctx context.Context, in rc.Params) (rc.Params, error) {
|
||||
fs.LogfCtx(ctx, nil, "attributed")
|
||||
fs.Logf(nil, "unattributed")
|
||||
return nil, nil
|
||||
}
|
||||
_, out, err = NewJob(ctx, unattributedFn, rc.Params{"_logs": true})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"attributed", "unattributed"}, logMessages(t, out["_logs"]))
|
||||
_, out, err = NewJob(ctx, unattributedFn, rc.Params{"_logs": rc.Params{"unattributed": false}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"attributed"}, logMessages(t, out["_logs"]))
|
||||
_, out, err = NewJob(ctx, unattributedFn, rc.Params{"_logs": rc.Params{"level": "NOTICE", "unattributed": true}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"attributed", "unattributed"}, logMessages(t, out["_logs"]))
|
||||
|
||||
// A bad level in the object form is an error
|
||||
_, _, err = NewJob(ctx, jobFn, rc.Params{"_logs": rc.Params{"level": "potato"}})
|
||||
require.Error(t, err)
|
||||
assert.True(t, rc.IsErrParamInvalid(err), err)
|
||||
|
||||
// Logs made while parsing the request are included
|
||||
_, out, err = NewJob(ctx, jobFn, rc.Params{
|
||||
"_logs": "ERROR",
|
||||
"_filter": rc.Params{"IncludeRule": []string{"*.jpg"}, "ExcludeRule": []string{"*.png"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
msgs := logMessages(t, out["_logs"])
|
||||
require.Len(t, msgs, 2)
|
||||
assert.Contains(t, msgs[0], "Using --filter is recommended")
|
||||
assert.Equal(t, "error from job", msgs[1])
|
||||
}
|
||||
|
||||
func TestLogsRequested(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
in rc.Params
|
||||
want bool
|
||||
}{
|
||||
{rc.Params{}, false},
|
||||
{rc.Params{"_logs": false}, false},
|
||||
{rc.Params{"_logs": "false"}, false},
|
||||
{rc.Params{"_logs": true}, true},
|
||||
{rc.Params{"_logs": "true"}, true},
|
||||
{rc.Params{"_logs": "INFO"}, true},
|
||||
{rc.Params{"_logs": "potato"}, true},
|
||||
{rc.Params{"_logs": 42}, true},
|
||||
} {
|
||||
assert.Equal(t, test.want, LogsRequested(test.in), fmt.Sprintf("%v", test.in))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteJobWithLogsError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
jobID.Store(0)
|
||||
setLogBuffer(t, 1*fs.Mebi)
|
||||
|
||||
testErr := rc.NewErrParamInvalid(errors.New("test error"))
|
||||
errorFn := func(ctx context.Context, in rc.Params) (out rc.Params, err error) {
|
||||
fs.Errorf(nil, "error from job")
|
||||
return nil, testErr
|
||||
}
|
||||
in := rc.Params{"_logs": true}
|
||||
_, _, err := NewJob(ctx, errorFn, in)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, testErr))
|
||||
assert.Equal(t, "test error", err.Error())
|
||||
|
||||
// Check the logs end up in the error response with the status intact
|
||||
out, status := rc.Error("test/path", in, err, http.StatusInternalServerError)
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
assert.Equal(t, "test error", out["error"])
|
||||
assert.Equal(t, []string{"error from job"}, logMessages(t, out["_logs"]))
|
||||
|
||||
// and via NewJobFromParams as used by job/batch and librclone
|
||||
call := rc.Calls.Get("rc/error")
|
||||
require.NotNil(t, call)
|
||||
out = NewJobFromParams(ctx, rc.Params{"_path": "rc/error", "_logs": true})
|
||||
assert.Contains(t, out, "error")
|
||||
assert.Contains(t, out, "_logs")
|
||||
}
|
||||
|
||||
// The stack trace from a panic must not be returned to the caller
|
||||
func TestExecuteJobWithLogsPanic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
jobID.Store(0)
|
||||
setLogBuffer(t, 1*fs.Mebi)
|
||||
|
||||
panicFn := func(ctx context.Context, in rc.Params) (out rc.Params, err error) {
|
||||
fs.Errorf(nil, "before panic")
|
||||
panic("boom")
|
||||
}
|
||||
_, _, err := NewJob(ctx, panicFn, rc.Params{"_logs": true})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "panic received: boom")
|
||||
out, _ := rc.Error("test/path", rc.Params{}, err, http.StatusInternalServerError)
|
||||
assert.Equal(t, []string{"before panic"}, logMessages(t, out["_logs"]))
|
||||
}
|
||||
|
||||
func TestRcJobStatusWithLogs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
jobID.Store(0)
|
||||
setLogBuffer(t, 1*fs.Mebi)
|
||||
|
||||
started := make(chan struct{})
|
||||
finish := make(chan struct{})
|
||||
jobFn := func(ctx context.Context, in rc.Params) (rc.Params, error) {
|
||||
fs.Logf(nil, "job started")
|
||||
close(started)
|
||||
<-finish
|
||||
fs.Logf(nil, "job finished")
|
||||
return nil, nil
|
||||
}
|
||||
// Logs made before the job starts must never be returned by it
|
||||
fs.Logf(nil, "before the job")
|
||||
|
||||
job, out, err := NewJob(ctx, jobFn, rc.Params{"_async": true, "_logs": true})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, out, "_logs")
|
||||
<-started
|
||||
|
||||
call := rc.Calls.Get("job/status")
|
||||
require.NotNil(t, call)
|
||||
status := func(extra ...any) rc.Params {
|
||||
in := rc.Params{"jobid": job.ID}
|
||||
for i := 0; i < len(extra); i += 2 {
|
||||
in[extra[i].(string)] = extra[i+1]
|
||||
}
|
||||
out, err := call.Fn(ctx, in)
|
||||
require.NoError(t, err)
|
||||
return out
|
||||
}
|
||||
// logs returns the _logs from the output of the status
|
||||
logs := func(out rc.Params) rc.Params {
|
||||
output, ok := out["output"].(map[string]any)
|
||||
require.True(t, ok, "output should be a map")
|
||||
return output["_logs"].(rc.Params)
|
||||
}
|
||||
|
||||
// Logs so far while the job is running
|
||||
out = status()
|
||||
assert.Equal(t, false, out["finished"])
|
||||
assert.Equal(t, []string{"job started"}, logMessages(t, logs(out)))
|
||||
|
||||
// since before the job starts at the job, so the logs made
|
||||
// before it aren't returned and aren't counted as lost
|
||||
out = status("since", 0)
|
||||
assert.Equal(t, []string{"job started"}, logMessages(t, logs(out)))
|
||||
|
||||
// Carry on from next which should return nothing new
|
||||
next := logs(out)["next"].(int64)
|
||||
out = status("since", next)
|
||||
assert.Empty(t, logMessages(t, logs(out)))
|
||||
assert.Equal(t, next, logs(out)["next"])
|
||||
|
||||
// Check limit
|
||||
fs.Logf(nil, "job continuing")
|
||||
out = status("limit", 1)
|
||||
assert.Equal(t, []string{"job started"}, logMessages(t, logs(out)))
|
||||
out = status("since", logs(out)["next"])
|
||||
assert.Equal(t, []string{"job continuing"}, logMessages(t, logs(out)))
|
||||
|
||||
finished := make(chan struct{})
|
||||
job.OnFinish(func() { close(finished) })
|
||||
close(finish)
|
||||
<-finished
|
||||
fs.Logf(nil, "after job")
|
||||
|
||||
// Only the logs made while the job was running once finished
|
||||
out = status()
|
||||
assert.Equal(t, true, out["finished"])
|
||||
assert.Equal(t, []string{"job started", "job continuing", "job finished"}, logMessages(t, logs(out)))
|
||||
|
||||
// No logs in the status if not asked for
|
||||
job, _, err = NewJob(ctx, func(ctx context.Context, in rc.Params) (rc.Params, error) {
|
||||
return nil, nil
|
||||
}, rc.Params{"_async": true})
|
||||
require.NoError(t, err)
|
||||
out = status()
|
||||
output, _ := out["output"].(map[string]any)
|
||||
assert.NotContains(t, output, "_logs")
|
||||
}
|
||||
|
||||
// Logs attributed to other jobs running at the same time aren't returned
|
||||
func TestExecuteJobWithLogsAttributed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
jobID.Store(0)
|
||||
setLogBuffer(t, 1*fs.Mebi)
|
||||
|
||||
aStarted := make(chan struct{})
|
||||
bLogged := make(chan struct{})
|
||||
aLogged := make(chan struct{})
|
||||
aFn := func(ctx context.Context, in rc.Params) (rc.Params, error) {
|
||||
fs.LogfCtx(ctx, nil, "A1")
|
||||
close(aStarted)
|
||||
<-bLogged
|
||||
fs.LogfCtx(ctx, nil, "A2")
|
||||
close(aLogged)
|
||||
return nil, nil
|
||||
}
|
||||
bFn := func(ctx context.Context, in rc.Params) (rc.Params, error) {
|
||||
fs.LogfCtx(ctx, nil, "B1")
|
||||
fs.Logf(nil, "unattributed")
|
||||
close(bLogged)
|
||||
<-aLogged
|
||||
fs.LogfCtx(ctx, nil, "B2")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Both jobs are in the same stats group so this checks the
|
||||
// logs are attributed to the job, not just the group
|
||||
jobA, _, err := NewJob(ctx, aFn, rc.Params{"_async": true, "_logs": true, "_group": "shared"})
|
||||
require.NoError(t, err)
|
||||
finished := make(chan struct{})
|
||||
jobA.OnFinish(func() { close(finished) })
|
||||
<-aStarted
|
||||
|
||||
// Job B runs while job A is running
|
||||
_, out, err := NewJob(ctx, bFn, rc.Params{"_logs": true, "_group": "shared"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"B1", "unattributed", "B2"}, logMessages(t, out["_logs"]))
|
||||
|
||||
<-finished
|
||||
call := rc.Calls.Get("job/status")
|
||||
require.NotNil(t, call)
|
||||
out, err = call.Fn(ctx, rc.Params{"jobid": jobA.ID})
|
||||
require.NoError(t, err)
|
||||
output, ok := out["output"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []string{"A1", "unattributed", "A2"}, logMessages(t, output["_logs"]))
|
||||
}
|
||||
@@ -66,6 +66,26 @@ func IsErrParamInvalid(err error) bool {
|
||||
return isInvalid
|
||||
}
|
||||
|
||||
// ErrorWithLogs is returned from a job started with the _logs
|
||||
// parameter which failed.
|
||||
//
|
||||
// Error will add Logs to the error response as _logs provided this
|
||||
// is the outermost error.
|
||||
type ErrorWithLogs struct {
|
||||
error
|
||||
Logs Params // logs made while the job was running
|
||||
}
|
||||
|
||||
// NewErrorWithLogs returns a new ErrorWithLogs from err and logs
|
||||
func NewErrorWithLogs(err error, logs Params) *ErrorWithLogs {
|
||||
return &ErrorWithLogs{error: err, Logs: logs}
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error
|
||||
func (e *ErrorWithLogs) Unwrap() error {
|
||||
return e.error
|
||||
}
|
||||
|
||||
// Reshape reshapes one blob of data into another via json serialization
|
||||
//
|
||||
// out should be a pointer type
|
||||
@@ -311,6 +331,11 @@ func (p Params) GetFsDuration(key string) (fs.Duration, error) {
|
||||
//
|
||||
// It returns a Params and an updated status code
|
||||
func Error(path string, in Params, err error, status int) (Params, int) {
|
||||
var logs Params
|
||||
if logsErr, ok := err.(*ErrorWithLogs); ok {
|
||||
logs = logsErr.Logs
|
||||
err = logsErr.error
|
||||
}
|
||||
// Adjust the status code for some well known errors
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrorDirNotFound) || errors.Is(err, fs.ErrorObjectNotFound):
|
||||
@@ -324,5 +349,8 @@ func Error(path string, in Params, err error, status int) (Params, int) {
|
||||
"input": in,
|
||||
"path": path,
|
||||
}
|
||||
if logs != nil {
|
||||
result["_logs"] = logs
|
||||
}
|
||||
return result, status
|
||||
}
|
||||
+29
-1
@@ -8,9 +8,11 @@
|
||||
package rc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
_ "net/http/pprof" // install the pprof http handlers
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
@@ -43,6 +45,11 @@ var OptionsInfo = fs.Options{{
|
||||
Default: false,
|
||||
Help: "Don't require auth for certain methods",
|
||||
Groups: "RC",
|
||||
}, {
|
||||
Name: "rc_log_calls",
|
||||
Default: true,
|
||||
Help: "Log each rc call and its reply at DEBUG level",
|
||||
Groups: "RC",
|
||||
}, {
|
||||
Name: "rc_web_gui",
|
||||
Default: false,
|
||||
@@ -99,7 +106,27 @@ var OptionsInfo = fs.Options{{
|
||||
SetDefault("metrics_addr", []string{})
|
||||
|
||||
func init() {
|
||||
fs.RegisterGlobalOptions(fs.OptionsInfo{Name: "rc", Opt: &Opt, Options: OptionsInfo})
|
||||
fs.RegisterGlobalOptions(fs.OptionsInfo{Name: "rc", Opt: &Opt, Options: OptionsInfo, Reload: optReload})
|
||||
}
|
||||
|
||||
// logCalls is Opt.LogCalls, kept here so it can be read without
|
||||
// racing with options/set changing the options.
|
||||
var logCalls atomic.Bool
|
||||
|
||||
// optReload makes the options which can be changed at runtime live.
|
||||
func optReload(ctx context.Context) error {
|
||||
logCalls.Store(Opt.LogCalls)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogCall logs an rc call at DEBUG level unless --rc-log-calls=false.
|
||||
//
|
||||
// This is used for the logs of each call and its reply which are
|
||||
// noise for a client reading the logs over the rc.
|
||||
func LogCall(text string, args ...any) {
|
||||
if logCalls.Load() {
|
||||
fs.Debugf(nil, text, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Options contains options for the remote control server
|
||||
@@ -112,6 +139,7 @@ type Options struct {
|
||||
Serve bool `config:"rc_serve"` // set to serve files from remotes
|
||||
ServeNoModTime bool `config:"rc_serve_no_modtime"` // don't read the modification time
|
||||
NoAuth bool `config:"rc_no_auth"` // set to disable auth checks on methods which require it
|
||||
LogCalls bool `config:"rc_log_calls"` // set to log each rc call and its reply at DEBUG level
|
||||
WebUI bool `config:"rc_web_gui"` // set to launch the web ui **DEPRECATED**
|
||||
WebGUIUpdate bool `config:"rc_web_gui_update"` // set to check new update **DEPRECATED**
|
||||
WebGUIForceUpdate bool `config:"rc_web_gui_force_update"` // set to force download new update **DEPRECATED**
|
||||
|
||||
@@ -2,8 +2,13 @@ package rc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -21,3 +26,39 @@ func TestWriteJSON(t *testing.T) {
|
||||
}
|
||||
`, buf.String())
|
||||
}
|
||||
|
||||
// Check --rc-log-calls can be turned off and on again with options/set
|
||||
func TestLogCall(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var buf bytes.Buffer
|
||||
oldLevel := fs.GetConfig(ctx).LogLevel
|
||||
fs.GetConfig(ctx).LogLevel = fs.LogLevelDebug
|
||||
fs.SetLogger(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
defer func() {
|
||||
fs.GetConfig(ctx).LogLevel = oldLevel
|
||||
fs.SetLogger(slog.NewTextHandler(io.Discard, nil))
|
||||
}()
|
||||
|
||||
set := func(logCalls bool) {
|
||||
call := Calls.Get("options/set")
|
||||
require.NotNil(t, call)
|
||||
_, err := call.Fn(ctx, Params{"rc": Params{"LogCalls": logCalls}})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
defer set(true)
|
||||
|
||||
// Logged by default
|
||||
LogCall("rc: %s: called", "core/stats")
|
||||
assert.Contains(t, buf.String(), "rc: core/stats: called")
|
||||
|
||||
// Not logged when turned off
|
||||
set(false)
|
||||
buf.Reset()
|
||||
LogCall("rc: %s: called", "core/stats")
|
||||
assert.Equal(t, "", buf.String())
|
||||
|
||||
// and logged again when turned back on
|
||||
set(true)
|
||||
LogCall("rc: %s: called", "core/stats")
|
||||
assert.Contains(t, buf.String(), "rc: core/stats: called")
|
||||
}
|
||||
@@ -254,6 +254,24 @@ func (s *Server) handlePost(w http.ResponseWriter, r *http.Request, path string)
|
||||
return
|
||||
}
|
||||
|
||||
// _logs returns the logs which aren't attributed to a job as well as
|
||||
// the job's own, so it can show the logs of anything else running.
|
||||
// Don't let unauthenticated users read them via the calls which
|
||||
// don't need authentication.
|
||||
if jobs.LogsRequested(in) && !s.noAuth && !s.server.UsingAuth() {
|
||||
writeError(path, in, w, errors.New("authentication must be set up on the rc server to use _logs or the --rc-no-auth flag must be in use"), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// A call which writes the response itself can't be run in the
|
||||
// background as the response is gone by the time it runs.
|
||||
if call.WritesResponse {
|
||||
if async, err := in.GetBool("_async"); err == nil && async {
|
||||
writeError(path, in, w, fmt.Errorf("can't use _async with %q", path), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
inOrig := in.Copy()
|
||||
|
||||
if call.NeedsRequest {
|
||||
@@ -265,7 +283,7 @@ func (s *Server) handlePost(w http.ResponseWriter, r *http.Request, path string)
|
||||
in["_response"] = w
|
||||
}
|
||||
|
||||
fs.Debugf(nil, "rc: %q: with parameters %+v", path, in)
|
||||
rc.LogCall("rc: %q: with parameters %+v", path, in)
|
||||
job, out, err := jobs.NewJob(ctx, call.Fn, in)
|
||||
if job != nil {
|
||||
w.Header().Add("x-rclone-jobid", fmt.Sprintf("%d", job.ID))
|
||||
@@ -278,7 +296,12 @@ func (s *Server) handlePost(w http.ResponseWriter, r *http.Request, path string)
|
||||
out = make(rc.Params)
|
||||
}
|
||||
|
||||
fs.Debugf(nil, "rc: %q: reply %+v: %v", path, out, err)
|
||||
if call.WritesResponse {
|
||||
// The call has written the response itself
|
||||
return
|
||||
}
|
||||
|
||||
rc.LogCall("rc: %q: reply %+v: %v", path, out, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if preferAsync {
|
||||
w.Header().Set("Preference-Applied", "respond-async")
|
||||
|
||||
@@ -910,6 +910,50 @@ func TestAuthRequired(t *testing.T) {
|
||||
testServer(t, tests, &opt)
|
||||
}
|
||||
|
||||
// _logs returns the logs which aren't attributed to a job as well as the
|
||||
// job's own, so it mustn't be usable without authentication on the calls
|
||||
// which don't need authentication.
|
||||
func TestLogsAuthRequired(t *testing.T) {
|
||||
tests := []testRun{{
|
||||
Name: "logs",
|
||||
URL: "rc/noop",
|
||||
Method: "POST",
|
||||
Body: `{"_logs":true}`,
|
||||
ContentType: "application/json",
|
||||
Status: http.StatusForbidden,
|
||||
Expected: `{
|
||||
"error": "authentication must be set up on the rc server to use _logs or the --rc-no-auth flag must be in use",
|
||||
"input": {
|
||||
"_logs": true
|
||||
},
|
||||
"path": "rc/noop",
|
||||
"status": 403
|
||||
}
|
||||
`,
|
||||
}, {
|
||||
Name: "no-logs",
|
||||
URL: "rc/noop",
|
||||
Method: "POST",
|
||||
Body: `{}`,
|
||||
ContentType: "application/json",
|
||||
Status: http.StatusOK,
|
||||
Expected: "{}\n",
|
||||
}, {
|
||||
Name: "logs-false",
|
||||
URL: "rc/noop",
|
||||
Method: "POST",
|
||||
Body: `{"_logs":false}`,
|
||||
ContentType: "application/json",
|
||||
Status: http.StatusOK,
|
||||
Expected: "{}\n",
|
||||
}}
|
||||
opt := newTestOpt()
|
||||
opt.Serve = false
|
||||
opt.Files = ""
|
||||
opt.NoAuth = false
|
||||
testServer(t, tests, &opt)
|
||||
}
|
||||
|
||||
// job/status returns the output of any job so it must need
|
||||
// authentication, e.g. so an unauthenticated user can't read the
|
||||
// output of config/dump started with _async by an authenticated one.
|
||||
@@ -1205,3 +1249,29 @@ func normalizeJSON(t *testing.T, jsonStr string) string {
|
||||
require.NoError(t, err, "JSON marshalling failed")
|
||||
return string(normalizedJSON)
|
||||
}
|
||||
|
||||
// A call which writes the response itself can't be run in the background
|
||||
func TestWritesResponseAsync(t *testing.T) {
|
||||
tests := []testRun{{
|
||||
Name: "async",
|
||||
URL: "core/events",
|
||||
Method: "POST",
|
||||
Body: `{"_async":true}`,
|
||||
ContentType: "application/json",
|
||||
Status: http.StatusBadRequest,
|
||||
Expected: `{
|
||||
"error": "can't use _async with \"core/events\"",
|
||||
"input": {
|
||||
"_async": true
|
||||
},
|
||||
"path": "core/events",
|
||||
"status": 400
|
||||
}
|
||||
`,
|
||||
}}
|
||||
opt := newTestOpt()
|
||||
opt.Serve = false
|
||||
opt.Files = ""
|
||||
opt.NoAuth = true
|
||||
testServer(t, tests, &opt)
|
||||
}
|
||||
@@ -22,6 +22,9 @@ type Call struct {
|
||||
Help string // multi-line markdown formatted help
|
||||
NeedsRequest bool // if set then this call will be passed the original request object as _request
|
||||
NeedsResponse bool // if set then this call will be passed the original response object as _response
|
||||
// if set then this call writes the HTTP response itself, so the
|
||||
// server doesn't write the usual JSON reply after it
|
||||
WritesResponse bool
|
||||
}
|
||||
|
||||
// Registry holds the list of all the registered remote control functions
|
||||
|
||||
+49
-49
@@ -177,7 +177,7 @@ func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.Delete
|
||||
|
||||
backlog := ci.MaxBacklog
|
||||
if s.checkFirst {
|
||||
fs.Infof(s.fdst, "Running all checks before starting transfers")
|
||||
fs.InfofCtx(ctx, s.fdst, "Running all checks before starting transfers")
|
||||
backlog = -1
|
||||
}
|
||||
var err error
|
||||
@@ -195,7 +195,7 @@ func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.Delete
|
||||
}
|
||||
if ci.MaxDuration > 0 {
|
||||
s.maxDurationEndTime = time.Now().Add(time.Duration(ci.MaxDuration))
|
||||
fs.Infof(s.fdst, "Transfer session %v deadline: %s", ci.CutoffMode, s.maxDurationEndTime.Format("2006/01/02 15:04:05"))
|
||||
fs.InfofCtx(ctx, s.fdst, "Transfer session %v deadline: %s", ci.CutoffMode, s.maxDurationEndTime.Format("2006/01/02 15:04:05"))
|
||||
}
|
||||
// If a max session duration has been defined add a deadline
|
||||
// to the main context if cutoff mode is hard. This will cut
|
||||
@@ -218,7 +218,7 @@ func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.Delete
|
||||
}
|
||||
if s.noTraverse && s.deleteMode != fs.DeleteModeOff {
|
||||
if !fi.HaveFilesFrom() {
|
||||
fs.Errorf(nil, "Ignoring --no-traverse with sync")
|
||||
fs.ErrorfCtx(ctx, nil, "Ignoring --no-traverse with sync")
|
||||
}
|
||||
s.noTraverse = false
|
||||
}
|
||||
@@ -240,21 +240,21 @@ func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.Delete
|
||||
if s.trackRenames {
|
||||
// Don't track renames for remotes without server-side move support.
|
||||
if !operations.CanServerSideMove(fdst) {
|
||||
fs.Errorf(fdst, "Ignoring --track-renames as the destination does not support server-side move or copy")
|
||||
fs.ErrorfCtx(ctx, fdst, "Ignoring --track-renames as the destination does not support server-side move or copy")
|
||||
s.trackRenames = false
|
||||
}
|
||||
if s.trackRenamesStrategy.hash() && s.commonHash == hash.None {
|
||||
fs.Errorf(fdst, "Ignoring --track-renames as the source and destination do not have a common hash")
|
||||
fs.ErrorfCtx(ctx, fdst, "Ignoring --track-renames as the source and destination do not have a common hash")
|
||||
s.trackRenames = false
|
||||
}
|
||||
|
||||
if s.trackRenamesStrategy.modTime() && s.modifyWindow == fs.ModTimeNotSupported {
|
||||
fs.Errorf(fdst, "Ignoring --track-renames as either the source or destination do not support modtime")
|
||||
fs.ErrorfCtx(ctx, fdst, "Ignoring --track-renames as either the source or destination do not support modtime")
|
||||
s.trackRenames = false
|
||||
}
|
||||
|
||||
if s.deleteMode == fs.DeleteModeOff {
|
||||
fs.Errorf(fdst, "Ignoring --track-renames as it doesn't work with copy or move, only sync")
|
||||
fs.ErrorfCtx(ctx, fdst, "Ignoring --track-renames as it doesn't work with copy or move, only sync")
|
||||
s.trackRenames = false
|
||||
}
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.Delete
|
||||
s.deleteMode = fs.DeleteModeAfter
|
||||
}
|
||||
if s.noTraverse {
|
||||
fs.Errorf(nil, "Ignoring --no-traverse with --track-renames")
|
||||
fs.ErrorfCtx(ctx, nil, "Ignoring --no-traverse with --track-renames")
|
||||
s.noTraverse = false
|
||||
}
|
||||
}
|
||||
@@ -324,7 +324,7 @@ func (s *syncCopyMove) processError(err error) {
|
||||
err = fserrors.NoRetryError(err)
|
||||
} else if err == accounting.ErrorMaxTransferLimitReachedGraceful {
|
||||
if s.inCtx.Err() == nil {
|
||||
fs.Logf(nil, "%v - stopping transfers", err)
|
||||
fs.LogfCtx(s.ctx, nil, "%v - stopping transfers", err)
|
||||
// Cancel the march and stop the pipes
|
||||
s.inCancel()
|
||||
}
|
||||
@@ -337,7 +337,7 @@ func (s *syncCopyMove) processError(err error) {
|
||||
switch {
|
||||
case fserrors.IsFatalError(err):
|
||||
if !s.aborting() {
|
||||
fs.Errorf(nil, "Cancelling sync due to fatal error: %v", err)
|
||||
fs.ErrorfCtx(s.ctx, nil, "Cancelling sync due to fatal error: %v", err)
|
||||
s.cancel()
|
||||
}
|
||||
s.fatalErr = err
|
||||
@@ -400,16 +400,16 @@ func (s *syncCopyMove) pairChecker(in *pipe, out *pipe, fraction int, wg *sync.W
|
||||
// src.Remote() (the correctly-cased name).
|
||||
if needTransfer {
|
||||
if _, statErr := s.fdst.NewObject(s.ctx, pair.Dst.Remote()); errors.Is(statErr, fs.ErrorObjectNotFound) {
|
||||
fs.Debugf(pair.Dst, "Skipping fix-case rename: destination removed for re-upload, will recreate at %s", src.Remote())
|
||||
fs.DebugfCtx(s.ctx, pair.Dst, "Skipping fix-case rename: destination removed for re-upload, will recreate at %s", src.Remote())
|
||||
pair.Dst = nil
|
||||
}
|
||||
}
|
||||
if pair.Dst != nil {
|
||||
if newDst, err := operations.Move(s.ctx, s.fdst, nil, src.Remote(), pair.Dst); err != nil {
|
||||
fs.Errorf(pair.Dst, "Error while attempting to rename to %s: %v", src.Remote(), err)
|
||||
fs.ErrorfCtx(s.ctx, pair.Dst, "Error while attempting to rename to %s: %v", src.Remote(), err)
|
||||
s.processError(err)
|
||||
} else {
|
||||
fs.Infof(pair.Dst, "Fixed case by renaming to: %s", src.Remote())
|
||||
fs.InfofCtx(s.ctx, pair.Dst, "Fixed case by renaming to: %s", src.Remote())
|
||||
pair.Dst = newDst
|
||||
}
|
||||
}
|
||||
@@ -418,7 +418,7 @@ func (s *syncCopyMove) pairChecker(in *pipe, out *pipe, fraction int, wg *sync.W
|
||||
// If files are treated as immutable, fail if destination exists and does not match
|
||||
if s.ci.Immutable && pair.Dst != nil {
|
||||
err := fs.CountError(s.ctx, fserrors.NoRetryError(fs.ErrorImmutableModified))
|
||||
fs.Errorf(pair.Dst, "Source and destination exist but do not match: %v", err)
|
||||
fs.ErrorfCtx(s.ctx, pair.Dst, "Source and destination exist but do not match: %v", err)
|
||||
s.processError(err)
|
||||
} else {
|
||||
if pair.Dst != nil {
|
||||
@@ -452,9 +452,9 @@ func (s *syncCopyMove) pairChecker(in *pipe, out *pipe, fraction int, wg *sync.W
|
||||
if s.DoMove {
|
||||
// Delete src if no error on copy
|
||||
if operations.SameObject(src, pair.Dst) {
|
||||
fs.Logf(src, "Not removing source file as it is the same file as the destination")
|
||||
fs.LogfCtx(s.ctx, src, "Not removing source file as it is the same file as the destination")
|
||||
} else if s.ci.IgnoreExisting {
|
||||
fs.Debugf(src, "Not removing source file as destination file exists and --ignore-existing is set")
|
||||
fs.DebugfCtx(s.ctx, src, "Not removing source file as destination file exists and --ignore-existing is set")
|
||||
} else if s.checkFirst && s.ci.OrderBy != "" {
|
||||
// If we want perfect ordering then use the transfers to delete the file
|
||||
//
|
||||
@@ -487,7 +487,7 @@ func (s *syncCopyMove) pairRenamer(in *pipe, out *pipe, fraction int, wg *sync.W
|
||||
src := pair.Src
|
||||
if !s.tryRename(src) {
|
||||
// pass on if not renamed
|
||||
fs.Debugf(src, "Need to transfer - No matching file found at Destination")
|
||||
fs.DebugfCtx(s.ctx, src, "Need to transfer - No matching file found at Destination")
|
||||
ok = out.Put(s.inCtx, pair)
|
||||
if !ok {
|
||||
return
|
||||
@@ -536,7 +536,7 @@ func (s *syncCopyMove) startCheckers() {
|
||||
// This stops the background checkers
|
||||
func (s *syncCopyMove) stopCheckers() {
|
||||
s.toBeChecked.Close()
|
||||
fs.Debugf(s.fdst, "Waiting for checks to finish")
|
||||
fs.DebugfCtx(s.ctx, s.fdst, "Waiting for checks to finish")
|
||||
s.checkerWg.Wait()
|
||||
}
|
||||
|
||||
@@ -552,7 +552,7 @@ func (s *syncCopyMove) startTransfers() {
|
||||
// This stops the background transfers
|
||||
func (s *syncCopyMove) stopTransfers() {
|
||||
s.toBeUploaded.Close()
|
||||
fs.Debugf(s.fdst, "Waiting for transfers to finish")
|
||||
fs.DebugfCtx(s.ctx, s.fdst, "Waiting for transfers to finish")
|
||||
s.transfersWg.Wait()
|
||||
}
|
||||
|
||||
@@ -574,7 +574,7 @@ func (s *syncCopyMove) stopRenamers() {
|
||||
return
|
||||
}
|
||||
s.toBeRenamed.Close()
|
||||
fs.Debugf(s.fdst, "Waiting for renames to finish")
|
||||
fs.DebugfCtx(s.ctx, s.fdst, "Waiting for renames to finish")
|
||||
s.renamerWg.Wait()
|
||||
}
|
||||
|
||||
@@ -626,7 +626,7 @@ func (s *syncCopyMove) stopDeleters() {
|
||||
// have been found have been removed from dstFiles already.
|
||||
func (s *syncCopyMove) deleteFiles(checkSrcMap bool) error {
|
||||
if accounting.Stats(s.ctx).Errored() && !s.ci.IgnoreErrors {
|
||||
fs.Errorf(s.fdst, "%v", fs.ErrorNotDeleting)
|
||||
fs.ErrorfCtx(s.ctx, s.fdst, "%v", fs.ErrorNotDeleting)
|
||||
// log all deletes as errors
|
||||
for remote, o := range s.dstFiles {
|
||||
if checkSrcMap {
|
||||
@@ -672,7 +672,7 @@ func (s *syncCopyMove) deleteEmptyDirectories(ctx context.Context, f fs.Fs, entr
|
||||
return nil
|
||||
}
|
||||
if accounting.Stats(ctx).Errored() && !s.ci.IgnoreErrors {
|
||||
fs.Errorf(f, "%v", fs.ErrorNotDeletingDirs)
|
||||
fs.ErrorfCtx(ctx, f, "%v", fs.ErrorNotDeletingDirs)
|
||||
return fs.ErrorNotDeletingDirs
|
||||
}
|
||||
|
||||
@@ -690,20 +690,20 @@ func (s *syncCopyMove) deleteEmptyDirectories(ctx context.Context, f fs.Fs, entr
|
||||
// TryRmdir only deletes empty directories
|
||||
err := operations.TryRmdir(ctx, f, dir.Remote())
|
||||
if err != nil {
|
||||
fs.Debugf(fs.LogDirName(f, dir.Remote()), "Failed to Rmdir: %v", err)
|
||||
fs.DebugfCtx(ctx, fs.LogDirName(f, dir.Remote()), "Failed to Rmdir: %v", err)
|
||||
errorCount++
|
||||
} else {
|
||||
okCount++
|
||||
}
|
||||
} else {
|
||||
fs.Errorf(f, "Not a directory: %v", entry)
|
||||
fs.ErrorfCtx(ctx, f, "Not a directory: %v", entry)
|
||||
}
|
||||
}
|
||||
if errorCount > 0 {
|
||||
fs.Debugf(f, "failed to delete %d directories", errorCount)
|
||||
fs.DebugfCtx(ctx, f, "failed to delete %d directories", errorCount)
|
||||
}
|
||||
if okCount > 0 {
|
||||
fs.Debugf(f, "deleted %d directories", okCount)
|
||||
fs.DebugfCtx(ctx, f, "deleted %d directories", okCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -780,7 +780,7 @@ func (s *syncCopyMove) renameID(obj fs.Object, renamesStrategy trackRenamesStrat
|
||||
var err error
|
||||
hash, err := obj.Hash(s.ctx, s.commonHash)
|
||||
if err != nil {
|
||||
fs.Debugf(obj, "Hash failed: %v", err)
|
||||
fs.DebugfCtx(s.ctx, obj, "Hash failed: %v", err)
|
||||
return ""
|
||||
}
|
||||
if hash == "" {
|
||||
@@ -852,7 +852,7 @@ func (s *syncCopyMove) popRenameMap(hash string, src fs.Object) (dst fs.Object)
|
||||
// makeRenameMap builds a map of the destination files by hash that
|
||||
// match sizes in the slice of objects in s.renameCheck
|
||||
func (s *syncCopyMove) makeRenameMap() {
|
||||
fs.Infof(s.fdst, "Making map for --track-renames")
|
||||
fs.InfofCtx(s.ctx, s.fdst, "Making map for --track-renames")
|
||||
|
||||
// first make a map of possible sizes we need to check
|
||||
possibleSizes := map[int64]struct{}{}
|
||||
@@ -887,7 +887,7 @@ func (s *syncCopyMove) makeRenameMap() {
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
fs.Infof(s.fdst, "Finished making map for --track-renames")
|
||||
fs.InfofCtx(s.ctx, s.fdst, "Finished making map for --track-renames")
|
||||
}
|
||||
|
||||
// tryRename renames an src object when doing track renames if
|
||||
@@ -912,7 +912,7 @@ func (s *syncCopyMove) tryRename(src fs.Object) bool {
|
||||
// Rename dst to have name src.Remote()
|
||||
_, err := operations.Move(s.ctx, s.fdst, dstOverwritten, src.Remote(), dst)
|
||||
if err != nil {
|
||||
fs.Debugf(src, "Failed to rename to %q: %v", dst.Remote(), err)
|
||||
fs.DebugfCtx(s.ctx, src, "Failed to rename to %q: %v", dst.Remote(), err)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -921,7 +921,7 @@ func (s *syncCopyMove) tryRename(src fs.Object) bool {
|
||||
delete(s.dstFiles, dst.Remote())
|
||||
s.dstFilesMu.Unlock()
|
||||
|
||||
fs.Infof(src, "Renamed from %q", dst.Remote())
|
||||
fs.InfofCtx(s.ctx, src, "Renamed from %q", dst.Remote())
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -934,7 +934,7 @@ func (s *syncCopyMove) tryRename(src fs.Object) bool {
|
||||
// dir is the start directory, "" for root
|
||||
func (s *syncCopyMove) run() error {
|
||||
if operations.Same(s.fdst, s.fsrc) && !s.allowOverlap {
|
||||
fs.Errorf(s.fdst, "Nothing to do as source and destination are the same")
|
||||
fs.ErrorfCtx(s.ctx, s.fdst, "Nothing to do as source and destination are the same")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -980,7 +980,7 @@ func (s *syncCopyMove) run() error {
|
||||
// Stop background checking and transferring pipeline
|
||||
s.stopCheckers()
|
||||
if s.checkFirst {
|
||||
fs.Infof(s.fdst, "Checks finished, now starting transfers")
|
||||
fs.InfofCtx(s.ctx, s.fdst, "Checks finished, now starting transfers")
|
||||
s.startTransfers()
|
||||
}
|
||||
s.stopRenamers()
|
||||
@@ -990,7 +990,7 @@ func (s *syncCopyMove) run() error {
|
||||
// Delete files after
|
||||
if s.deleteMode == fs.DeleteModeAfter {
|
||||
if s.currentError() != nil && !s.ci.IgnoreErrors {
|
||||
fs.Errorf(s.fdst, "%v", fs.ErrorNotDeleting)
|
||||
fs.ErrorfCtx(s.ctx, s.fdst, "%v", fs.ErrorNotDeleting)
|
||||
} else {
|
||||
s.processError(s.deleteFiles(false))
|
||||
}
|
||||
@@ -1004,7 +1004,7 @@ func (s *syncCopyMove) run() error {
|
||||
// Prune empty directories
|
||||
if s.deleteMode != fs.DeleteModeOff {
|
||||
if s.currentError() != nil && !s.ci.IgnoreErrors {
|
||||
fs.Errorf(s.fdst, "%v", fs.ErrorNotDeletingDirs)
|
||||
fs.ErrorfCtx(s.ctx, s.fdst, "%v", fs.ErrorNotDeletingDirs)
|
||||
} else {
|
||||
s.processError(s.deleteEmptyDirectories(s.ctx, s.fdst, s.dstEmptyDirs))
|
||||
}
|
||||
@@ -1023,13 +1023,13 @@ func (s *syncCopyMove) run() error {
|
||||
|
||||
// If the duration was exceeded then add a Fatal Error so we don't retry
|
||||
if !s.maxDurationEndTime.IsZero() && time.Since(s.maxDurationEndTime) > 0 {
|
||||
fs.Errorf(s.fdst, "%v", ErrorMaxDurationReachedFatal)
|
||||
fs.ErrorfCtx(s.ctx, s.fdst, "%v", ErrorMaxDurationReachedFatal)
|
||||
s.processError(ErrorMaxDurationReachedFatal)
|
||||
}
|
||||
|
||||
// Print nothing to transfer message if there were no transfers and no errors
|
||||
if s.deleteMode != fs.DeleteModeOnly && accounting.Stats(s.ctx).GetTransfers() == 0 && s.currentError() == nil {
|
||||
fs.Infof(nil, "There was nothing to transfer")
|
||||
fs.InfofCtx(s.ctx, nil, "There was nothing to transfer")
|
||||
}
|
||||
|
||||
// cancel the contexts to free resources
|
||||
@@ -1176,7 +1176,7 @@ func (s *syncCopyMove) copyDirMetadata(ctx context.Context, f fs.Fs, dst fs.Dire
|
||||
}
|
||||
s.setDirModTimes = append(s.setDirModTimes, set)
|
||||
s.setDirModTimeMu.Unlock()
|
||||
fs.Debugf(nil, "Added delayed dir = %q, newDst=%v", dir, newDst)
|
||||
fs.DebugfCtx(ctx, nil, "Added delayed dir = %q, newDst=%v", dir, newDst)
|
||||
}
|
||||
s.processError(err)
|
||||
if err != nil {
|
||||
@@ -1231,7 +1231,7 @@ func (s *syncCopyMove) setDelayedDirModTimes(ctx context.Context) error {
|
||||
_, err = operations.SetDirModTime(gCtx, s.fdst, item.dst, item.dir, item.modTime)
|
||||
}
|
||||
if err != nil {
|
||||
fs.Errorf(item.dir, "Failed to update directory timestamp or metadata: %v", err)
|
||||
fs.ErrorfCtx(gCtx, item.dir, "Failed to update directory timestamp or metadata: %v", err)
|
||||
errCount.Add(err)
|
||||
}
|
||||
return nil // don't return errors, just count them
|
||||
@@ -1271,7 +1271,7 @@ func (s *syncCopyMove) SrcOnly(src fs.DirEntry) (recurse bool) {
|
||||
}
|
||||
if !NoNeedTransfer {
|
||||
// No need to check since doesn't exist
|
||||
fs.Debugf(src, "Need to transfer - File not found at Destination")
|
||||
fs.DebugfCtx(s.ctx, src, "Need to transfer - File not found at Destination")
|
||||
s.markDirModifiedObject(x)
|
||||
ok := s.toBeUploaded.Put(s.inCtx, fs.ObjectPair{Src: x, Dst: nil})
|
||||
if !ok {
|
||||
@@ -1313,7 +1313,7 @@ func (s *syncCopyMove) Match(ctx context.Context, dst, src fs.DirEntry) (recurse
|
||||
} else {
|
||||
// FIXME src is file, dst is directory
|
||||
err := errors.New("can't overwrite directory with file")
|
||||
fs.Errorf(dst, "%v", err)
|
||||
fs.ErrorfCtx(ctx, dst, "%v", err)
|
||||
s.processError(err)
|
||||
s.logger(ctx, operations.TransferError, srcX, dstX, err)
|
||||
}
|
||||
@@ -1335,10 +1335,10 @@ func (s *syncCopyMove) Match(ctx context.Context, dst, src fs.DirEntry) (recurse
|
||||
// Fix each dir before recursing into subdirs and files
|
||||
err := operations.DirMoveCaseInsensitive(s.ctx, s.fdst, dst.Remote(), src.Remote())
|
||||
if err != nil {
|
||||
fs.Errorf(dst, "Error while attempting to rename to %s: %v", src.Remote(), err)
|
||||
fs.ErrorfCtx(ctx, dst, "Error while attempting to rename to %s: %v", src.Remote(), err)
|
||||
s.processError(err)
|
||||
} else {
|
||||
fs.Infof(dst, "Fixed case by renaming to: %s", src.Remote())
|
||||
fs.InfofCtx(ctx, dst, "Fixed case by renaming to: %s", src.Remote())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1346,7 +1346,7 @@ func (s *syncCopyMove) Match(ctx context.Context, dst, src fs.DirEntry) (recurse
|
||||
}
|
||||
// FIXME src is dir, dst is file
|
||||
err := errors.New("can't overwrite file with directory")
|
||||
fs.Errorf(dst, "%v", err)
|
||||
fs.ErrorfCtx(ctx, dst, "%v", err)
|
||||
s.processError(err)
|
||||
s.logger(ctx, operations.TransferError, src.(fs.ObjectInfo), dst.(fs.ObjectInfo), err)
|
||||
default:
|
||||
@@ -1416,7 +1416,7 @@ func Transform(ctx context.Context, fdst fs.Fs, deleteEmptySrcDirs bool, copyEmp
|
||||
func MoveDir(ctx context.Context, fdst, fsrc fs.Fs, deleteEmptySrcDirs bool, copyEmptySrcDirs bool) error {
|
||||
fi := filter.GetConfig(ctx)
|
||||
if operations.Same(fdst, fsrc) {
|
||||
fs.Errorf(fdst, "Nothing to do as source and destination are the same")
|
||||
fs.ErrorfCtx(ctx, fdst, "Nothing to do as source and destination are the same")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1425,17 +1425,17 @@ func MoveDir(ctx context.Context, fdst, fsrc fs.Fs, deleteEmptySrcDirs bool, cop
|
||||
if operations.SkipDestructive(ctx, fdst, "server-side directory move") {
|
||||
return nil
|
||||
}
|
||||
fs.Debugf(fdst, "Using server-side directory move")
|
||||
fs.DebugfCtx(ctx, fdst, "Using server-side directory move")
|
||||
err := fdstDirMove(ctx, fsrc, "", "")
|
||||
switch err {
|
||||
case fs.ErrorCantDirMove, fs.ErrorDirExists:
|
||||
fs.Infof(fdst, "Server side directory move failed - fallback to file moves: %v", err)
|
||||
fs.InfofCtx(ctx, fdst, "Server side directory move failed - fallback to file moves: %v", err)
|
||||
case nil:
|
||||
fs.Infof(fdst, "Server side directory move succeeded")
|
||||
fs.InfofCtx(ctx, fdst, "Server side directory move succeeded")
|
||||
return nil
|
||||
default:
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(fdst, "Server side directory move failed: %v", err)
|
||||
fs.ErrorfCtx(ctx, fdst, "Server side directory move failed: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ package sync
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
@@ -26,6 +28,7 @@ import (
|
||||
"github.com/rclone/rclone/fs/filter"
|
||||
"github.com/rclone/rclone/fs/fserrors"
|
||||
"github.com/rclone/rclone/fs/hash"
|
||||
"github.com/rclone/rclone/fs/log"
|
||||
"github.com/rclone/rclone/fs/operations"
|
||||
"github.com/rclone/rclone/fstest"
|
||||
"github.com/rclone/rclone/lib/transform"
|
||||
@@ -64,6 +67,49 @@ func TestCopyWithDryRun(t *testing.T) {
|
||||
r.CheckRemoteItems(t)
|
||||
}
|
||||
|
||||
// Check the per file logs from a copy are attributed to the stats group
|
||||
func TestCopyLogsAttributedToStatsGroup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := fstest.NewRun(t)
|
||||
r.WriteFile("sub dir/hello world", "hello world", t1)
|
||||
r.Mkdir(ctx, r.Fremote)
|
||||
|
||||
// Log at INFO level so "Copied (new)" is logged
|
||||
ci := fs.GetConfig(ctx)
|
||||
oldLogLevel := ci.LogLevel
|
||||
ci.LogLevel = fs.LogLevelInfo
|
||||
oldSlogLevel := log.Handler.SetLevel(slog.LevelInfo)
|
||||
defer func() {
|
||||
ci.LogLevel = oldLogLevel
|
||||
log.Handler.SetLevel(oldSlogLevel)
|
||||
}()
|
||||
|
||||
// Capture the JSON logs
|
||||
var mu mutex.Mutex
|
||||
var entries []map[string]any
|
||||
remove := log.Handler.AddOutput(true, func(level slog.Level, text string) {
|
||||
var entry map[string]any
|
||||
assert.NoError(t, json.Unmarshal([]byte(text), &entry), text)
|
||||
mu.Lock()
|
||||
entries = append(entries, entry)
|
||||
mu.Unlock()
|
||||
})
|
||||
defer remove()
|
||||
|
||||
err := CopyDir(accounting.WithStatsGroup(ctx, "test-group"), r.Fremote, r.Flocal, false)
|
||||
require.NoError(t, err)
|
||||
remove()
|
||||
|
||||
found := false
|
||||
for _, entry := range entries {
|
||||
if entry["msg"] == "Copied (new)" && entry["object"] == "sub dir/hello world" {
|
||||
assert.Equal(t, "test-group", entry["group"])
|
||||
found = true
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "didn't find Copied log in %v", entries)
|
||||
}
|
||||
|
||||
// Now without dry run
|
||||
func TestCopy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
+2
-2
@@ -172,7 +172,7 @@ func listRwalk(ctx context.Context, f fs.Fs, path string, includeAll bool, maxLe
|
||||
if err != nil {
|
||||
listErr = err
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(path, "error listing: %v", err)
|
||||
fs.ErrorfCtx(ctx, path, "error listing: %v", err)
|
||||
return nil
|
||||
}
|
||||
listType.Filter(&entries)
|
||||
@@ -424,7 +424,7 @@ func walk(ctx context.Context, f fs.Fs, path string, includeAll bool, maxLevel i
|
||||
if err != nil && err != ErrorSkipDir {
|
||||
traversing.Done()
|
||||
err = fs.CountError(ctx, err)
|
||||
fs.Errorf(job.remote, "error listing: %v", err)
|
||||
fs.ErrorfCtx(ctx, job.remote, "error listing: %v", err)
|
||||
closeQuit()
|
||||
// Send error to error channel if space
|
||||
select {
|
||||
|
||||
@@ -112,7 +112,7 @@ func RPC(method string, input string) (output string, status int) {
|
||||
//in["_response"] = w
|
||||
}
|
||||
|
||||
fs.Debugf(nil, "rc: %q: with parameters %+v", method, in)
|
||||
rc.LogCall("rc: %q: with parameters %+v", method, in)
|
||||
|
||||
_, out, err := jobs.NewJob(context.Background(), call.Fn, in)
|
||||
if err != nil {
|
||||
@@ -122,7 +122,7 @@ func RPC(method string, input string) (output string, status int) {
|
||||
out = make(rc.Params)
|
||||
}
|
||||
|
||||
fs.Debugf(nil, "rc: %q: reply %+v: %v", method, out, err)
|
||||
rc.LogCall("rc: %q: reply %+v: %v", method, out, err)
|
||||
|
||||
var w strings.Builder
|
||||
err = rc.WriteJSON(&w, out)
|
||||
|
||||
Reference in new issue
Block a user