Skip to content

Commit e2afb88

Browse files
authored
Merge pull request #1625 from Altinity/fix-watch-env-override
Keep --env overrides across watch and server config reloads
2 parents 04940b5 + 32fe429 commit e2afb88

5 files changed

Lines changed: 159 additions & 16 deletions

File tree

‎ChangeLog.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ NEW FEATURES
77
BUG FIXES
88
- the SQL query log no longer prints passwords embedded in URLs: `pass=` / `password=` query string parameters and `user:password@` are written as `[MASKED]`, with `api.create_integration_tables: true` and `api.password` set every `server` start logged the DDL of `system.backup_actions`, `system.backup_list` and `system.backup_version` with the API password in plain text inside the `URL()` engine argument, at `info` level with the default `clickhouse.log_sql_queries: true`, the query sent to ClickHouse is unchanged, fix [#1630](https://github.com/Altinity/clickhouse-backup/issues/1630)
99
- `s3.chunk_size` is now also used when `ceil(file size / chunk_size)` is exactly `s3.max_parts_count`, previously it fell back to `file size / max_parts_count`, ReadMe.md now shows the real default `5242880` and the semantics of `s3.chunk_size`, raise it to upload big files as fewer bigger parts instead of `max_parts_count=1` workaround, each multipart upload allocates about `(s3.concurrency + 2) * chunk_size` bytes, `s3.chunk_size` and `azblob.chunk_size` also apply to uploads into object disks during restore, fix [#1142](https://github.com/Altinity/clickhouse-backup/issues/1142)
10+
- `watch --env=KEY=VALUE` and `server --env=KEY=VALUE` no longer lose the `--env` overrides, config reload on every `watch` iteration (also inside `server --watch` and `POST /backup/watch`) read only the config file and the real environment, so e.g. `--env=REMOTE_STORAGE=ftp` turned back into the config file value and backups went to the wrong remote storage, and `server` never applied its `--env` to its own config at all (startup, `POST /restart`, `SIGHUP` and every API handler reload); `--env` is now applied to the process environment once at startup and kept for the whole process lifetime, so `/backup/actions` commands and operations resumed after restart (`api.complete_resumable_after_restart`) inherit the `server --env` too, `--env` passed inside a `/backup/actions` command still wins and is restored after the command; a duplicated `--env` key keeps the last value; `server` now exits on an invalid config (including a bad `--env` value) instead of retrying every 5 seconds forever, only the ClickHouse connection is still awaited; `general.disable_environment_override: true` still ignores `--env`
1011
- `--resume` (and `use_resumable_state: true`) now starts from scratch when the config which defines where the resumable state keys point to changed since the interrupted run: `remote_storage`, the active storage location (`s3` endpoint/bucket/path, `gcs` endpoint/bucket/path, `azblob` endpoint_suffix/account_name/container/path, `cos` url/path, `ftp` address/path, `sftp` address/port/path; `path` is not used by `create`), `object_disk_path`, the customer-provided encryption key (`s3.sse_customer_key`, `gcs.encryption_key`, `azblob.sse_key`, only its digest is stored), `compression_format` for `upload` / `create_remote`, and `restore_database_mapping` / `restore_table_mapping` for `restore` / `restore_remote --streaming`; the state keys are relative to the storage path, so `upload --resume` after changing `s3.path` used to skip every file already uploaded to the old path; tuning concurrency, retries, credentials or `log_level` still keeps the resume; the discarded state is logged as a warning with the changed parameters only; `ResumeOperationsAfterRestart` now passes `--restore-database-mapping` / `--restore-table-mapping` back to the resumed `restore` / `restore_remote --streaming`. **Upgrade note**: resumable states written by previous versions have no config fingerprint and are discarded on the first `--resume` after upgrading, so an interrupted operation restarts from scratch once, fix [#1010](https://github.com/Altinity/clickhouse-backup/issues/1010)
1112
- `watch` no longer treats a failed `backups_to_keep_remote` / `backups_to_keep_local` retention or `--delete-source` after a completely uploaded backup as a failed upload: the new backup becomes the base of the next increment (previously the next increment was diffed from a stale base) and the failure is not counted towards `too many errors ... abort watching`, so a permanently failing post-upload step no longer stops `watch`, it is reported only by the error log, `/backup/status`, `system.backup_actions` and the callback, with `api.enable_metrics` the `failed_create_remotes` counter keeps growing and `last_create_remote_status` stays `0` while new backups are uploaded; all post-upload steps now run even when a previous one failed and their errors are reported together; with `--schedule` the `full_type: rebase` and `delete_previous_cycle` steps also proceed, a failed rebase is reported together with the post-upload error and the backup is not used as the base; `use_embedded_backup_restore_cluster` initiator waits for the worker nodes before the backup is used as the base, a post-upload failure on a worker node is still counted as a failed upload; `create_remote --streaming` no longer reports such a complete backup as partial; one-shot `create_remote` and `upload` still exit with a non-zero code, fix [#1535](https://github.com/Altinity/clickhouse-backup/issues/1535)
1213
- `restore --schema --rm` without `restore_schema_on_cluster` on a replica of `ENGINE=Replicated` database, when the database schema was already restored from another replica (ACM restore sequence and `How to back up / restore a sharded cluster` in Examples.md), no longer fails with `code: 529 ... Cannot enqueue query on this replica, because it has replication lag`, `code: 36 ... It's not allowed to specify explicit zookeeper_path and replica_name` or `Table ... already exists`: local `DROP DATABASE` of a Replicated database is skipped (it removed only the current replica, which then recovered from the database DDL log concurrently with the following DROP/CREATE TABLE, tables are still dropped and their DROP TABLE is replicated), `SYSTEM SYNC DATABASE REPLICA` is executed before and after dropping tables (ClickHouse 22.5+), and `{uuid}` macro of `Replicated*MergeTree` path is no longer replaced with the backup table UUID and the replication path is no longer rebound to `default_replica_path` inside a Replicated database, fix [#1611](https://github.com/Altinity/clickhouse-backup/issues/1611)

‎pkg/config/config.go‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"time"
1414

1515
"github.com/Altinity/clickhouse-backup/v2/pkg/log_helper"
16+
"github.com/Altinity/clickhouse-backup/v2/pkg/status"
1617
"github.com/aws/aws-sdk-go-v2/aws"
1718
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
1819
"github.com/google/shlex"
@@ -1123,7 +1124,11 @@ func GetConfigFromCli(ctx *cli.Command) *Config {
11231124
if err != nil {
11241125
log.Fatal().Stack().Err(err).Send()
11251126
}
1126-
RestoreEnvVars(oldEnvValues)
1127+
// --env stays in the process environment, so config reloads of long-lived `watch` and `server` see it with plain LoadConfig,
1128+
// only commands re-entered from the API server restore it, their own --env shall not leak into the server process
1129+
if ctx.Int("command-id") != status.NotFromAPI {
1130+
RestoreEnvVars(oldEnvValues)
1131+
}
11271132
// `restore`/`restore_remote` expose --rebind-replica-path-if-exists to override the config value per invocation.
11281133
// Only override when passed as true, so the flag's default `false` doesn't clobber a `true` from the config file.
11291134
// Bool is false for commands that don't declare the flag, so this is safe to evaluate for every command.
@@ -1273,10 +1278,13 @@ func OverrideEnvVars(ctx *cli.Command) map[string]oldEnvValues {
12731278

12741279
processEnvFromCli(func(envVariable []string) {
12751280
log.Info().Msgf("override %s=%s", envVariable[0], maskSensitiveEnvValue(envVariable[0], envVariable[1]))
1276-
oldValue, wasPresent := os.LookupEnv(envVariable[0])
1277-
oldValues[envVariable[0]] = oldEnvValues{
1278-
OldValue: oldValue,
1279-
WasPresent: wasPresent,
1281+
// duplicated --env key, keep the value from before the first override, otherwise restore leaves the first override set
1282+
if _, alreadyOverridden := oldValues[envVariable[0]]; !alreadyOverridden {
1283+
oldValue, wasPresent := os.LookupEnv(envVariable[0])
1284+
oldValues[envVariable[0]] = oldEnvValues{
1285+
OldValue: oldValue,
1286+
WasPresent: wasPresent,
1287+
}
12801288
}
12811289
if err := os.Setenv(envVariable[0], envVariable[1]); err != nil {
12821290
log.Warn().Msgf("can't override %s=%s, error: %v", envVariable[0], maskSensitiveEnvValue(envVariable[0], envVariable[1]), err)

‎pkg/config/config_test.go‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,11 @@ func TestDisableEnvironmentOverride(t *testing.T) {
237237
t.Fatalf("expected --env override %q, got %q", "from-cli", cfg.S3.Bucket)
238238
}
239239

240-
// disable_environment_override: true ignores --env and doesn't touch the process environment
240+
// disable_environment_override: true ignores --env and doesn't touch the process environment,
241+
// the --env above stays in the process environment by design, see TestGetConfigFromCliKeepsEnvOverride
242+
if err = os.Unsetenv("S3_BUCKET"); err != nil {
243+
t.Fatalf("can't unset S3_BUCKET: %v", err)
244+
}
241245
writeConfig(true)
242246
cfg = GetConfigFromCli(newCliContext("S3_BUCKET=from-cli"))
243247
if cfg.S3.Bucket != "from-file" {
@@ -248,6 +252,72 @@ func TestDisableEnvironmentOverride(t *testing.T) {
248252
}
249253
}
250254

255+
// TestGetConfigFromCliKeepsEnvOverride - `watch` and `server` reload config with plain LoadConfig on every iteration / API call,
256+
// --env stays in the process environment after GetConfigFromCli, so reloads keep it
257+
func TestGetConfigFromCliKeepsEnvOverride(t *testing.T) {
258+
configPath := filepath.Join(t.TempDir(), "config.yml")
259+
writeConfig := func(disable bool) {
260+
content := fmt.Sprintf("general:\n remote_storage: \"s3\"\n backups_to_keep_remote: 3\n disable_environment_override: %v\ns3:\n bucket: \"from-file\"\n", disable)
261+
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
262+
t.Fatalf("can't write config file: %v", err)
263+
}
264+
}
265+
// t.Setenv snapshots the ambient values and restores them after the test
266+
resetEnv := func() {
267+
for _, name := range []string{"REMOTE_STORAGE", "BACKUPS_TO_KEEP_REMOTE"} {
268+
t.Setenv(name, "")
269+
if err := os.Unsetenv(name); err != nil {
270+
t.Fatalf("can't unset %s: %v", name, err)
271+
}
272+
}
273+
t.Setenv("S3_BUCKET", "ambient")
274+
}
275+
// duplicated key, the last value wins
276+
envArgs := []string{"--env", "REMOTE_STORAGE=none", "--env", "BACKUPS_TO_KEEP_REMOTE=7", "--env", "S3_BUCKET=first", "--env", "S3_BUCKET=last"}
277+
ctx := parseTestFlags(t, append([]string{"--config", configPath}, envArgs...)...)
278+
assertOverridden := func(cfg *Config, step string) {
279+
t.Helper()
280+
if cfg.General.RemoteStorage != "none" || cfg.General.BackupsToKeepRemote != 7 || cfg.S3.Bucket != "last" {
281+
t.Fatalf("%s expected --env overrides, got remote_storage=%q backups_to_keep_remote=%d s3->bucket=%q", step, cfg.General.RemoteStorage, cfg.General.BackupsToKeepRemote, cfg.S3.Bucket)
282+
}
283+
}
284+
285+
resetEnv()
286+
writeConfig(false)
287+
assertOverridden(GetConfigFromCli(ctx), "GetConfigFromCli")
288+
for i := 0; i < 2; i++ {
289+
cfg, err := LoadConfig(GetConfigPath(ctx))
290+
if err != nil {
291+
t.Fatalf("LoadConfig: %v", err)
292+
}
293+
assertOverridden(cfg, fmt.Sprintf("reload %d", i))
294+
}
295+
296+
// commands re-entered from the API server restore the environment, their --env shall not leak into the server process
297+
resetEnv()
298+
cfg := GetConfigFromCli(parseTestFlags(t, append([]string{"--config", configPath, "--command-id", "5"}, envArgs...)...))
299+
assertOverridden(cfg, "re-entered GetConfigFromCli")
300+
for _, name := range []string{"REMOTE_STORAGE", "BACKUPS_TO_KEEP_REMOTE"} {
301+
if _, present := os.LookupEnv(name); present {
302+
t.Fatalf("--env %s of the re-entered command leaked into the process environment", name)
303+
}
304+
}
305+
if got := os.Getenv("S3_BUCKET"); got != "ambient" {
306+
t.Fatalf("expected ambient S3_BUCKET restored after duplicated --env, got %q", got)
307+
}
308+
309+
// disable_environment_override: true ignores --env and doesn't touch the process environment
310+
resetEnv()
311+
writeConfig(true)
312+
cfg = GetConfigFromCli(ctx)
313+
if cfg.General.RemoteStorage != "s3" || cfg.General.BackupsToKeepRemote != 3 || cfg.S3.Bucket != "from-file" {
314+
t.Fatalf("expected config file values with disable_environment_override, got remote_storage=%q backups_to_keep_remote=%d s3->bucket=%q", cfg.General.RemoteStorage, cfg.General.BackupsToKeepRemote, cfg.S3.Bucket)
315+
}
316+
if _, present := os.LookupEnv("REMOTE_STORAGE"); present {
317+
t.Fatal("--env leaked into the process environment while disable_environment_override is enabled")
318+
}
319+
}
320+
251321
func newEnvContext(t *testing.T, values ...string) *cli.Command {
252322
t.Helper()
253323
return parseTestFlags(t, envArgs(values)...)
@@ -274,6 +344,7 @@ func parseTestFlags(t *testing.T, args ...string) *cli.Command {
274344
Flags: []cli.Flag{
275345
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Value: DefaultConfigPath},
276346
&cli.StringSliceFlag{Name: "environment-override", Aliases: []string{"env"}},
347+
&cli.IntFlag{Name: "command-id", Value: -1},
277348
},
278349
Action: func(_ context.Context, c *cli.Command) error {
279350
parsed = c

‎pkg/server/server.go‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,21 +75,14 @@ var (
7575

7676
// Run - expose CLI commands as REST API
7777
func Run(cliCtx *cli.Command, newCliApp func() *cli.Command, configPath string, clickhouseBackupVersion string) error {
78-
var (
79-
cfg *config.Config
80-
err error
81-
)
8278
// from here on every re-entry into cliApp comes from an API handler which owns
8379
// its own status row, see status.SetAPIServerMode
8480
status.SetAPIServerMode()
81+
// applies --env to the process environment once, so every later ReloadConfig and cliApp re-entry sees it,
82+
// invalid config, including a bad --env value, is fatal, only ClickHouse connectivity is worth waiting for
83+
cfg := config.GetConfigFromCli(cliCtx)
8584
log.Debug().Msg("Wait for ClickHouse")
8685
for {
87-
cfg, err = config.LoadConfig(configPath)
88-
if err != nil {
89-
log.Error().Stack().Err(err).Send()
90-
time.Sleep(5 * time.Second)
91-
continue
92-
}
9386
ch := clickhouse.NewClickHouse(&cfg.ClickHouse)
9487
if connErr := ch.Connect(); connErr != nil {
9588
log.Error().Stack().Err(connErr).Send()
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//go:build integration
2+
3+
package main
4+
5+
import (
6+
"strings"
7+
"testing"
8+
"time"
9+
10+
"github.com/rs/zerolog/log"
11+
)
12+
13+
// TestWatchEnvOverride - `watch` reloads config on every iteration, the reload shall keep --env overrides,
14+
// otherwise backups land in the config file remote path instead of the --env one
15+
func TestWatchEnvOverride(t *testing.T) {
16+
env, r := NewTestEnvironment(t)
17+
defer env.Cleanup(t, r)
18+
env.connectWithWait(t, r, 0*time.Second, 1*time.Second, 1*time.Minute)
19+
20+
dbName := "test_watch_env_override"
21+
prefix := "envwatch"
22+
// keep {version} macro, it isolates concurrent CI runs in the shared bucket
23+
envArgs := "--env S3_PATH=backup_watch_env/{cluster}/{shard}/{version}"
24+
25+
listMatched := func(args string) []string {
26+
out, _ := env.DockerExecOut("clickhouse-backup", "bash", "-ce", "clickhouse-backup -c /etc/clickhouse-backup/config-s3.yml "+args+" list remote 2>/dev/null | cut -d ' ' -f 1 | grep '^"+prefix+"-' || true")
27+
return strings.Fields(out)
28+
}
29+
cleanBackups := func() {
30+
for _, args := range []string{envArgs, ""} {
31+
for _, backupName := range listMatched(args) {
32+
env.DockerExecNoError(r, "clickhouse-backup", "bash", "-ce", "clickhouse-backup -c /etc/clickhouse-backup/config-s3.yml "+args+" delete --force remote "+backupName+" 2>/dev/null || true")
33+
}
34+
}
35+
// backups killed mid-flight by pkill may not show up in `list local`, remove leftovers from all backup disks
36+
env.DockerExecNoError(r, "clickhouse-backup", "bash", "-ce", "rm -rf /var/lib/clickhouse/backup/"+prefix+"-* /hdd1_data/backup/"+prefix+"-* /hdd2_data/backup/"+prefix+"-* 2>/dev/null || true")
37+
}
38+
cleanBackups()
39+
r.NoError(env.dropDatabase(dbName, true))
40+
defer func() {
41+
cleanBackups()
42+
r.NoError(env.dropDatabase(dbName, true))
43+
}()
44+
45+
env.queryWithNoError(t, r, "CREATE DATABASE "+dbName)
46+
env.queryWithNoError(t, r, "CREATE TABLE "+dbName+".t1 (id UInt64) ENGINE=MergeTree() ORDER BY id")
47+
env.queryWithNoError(t, r, "INSERT INTO "+dbName+".t1 SELECT number FROM numbers(1000)")
48+
49+
env.DockerExecBackgroundNoError(r, "clickhouse-backup", "bash", "-ce",
50+
"BACKUPS_TO_KEEP_REMOTE=0 clickhouse-backup -c /etc/clickhouse-backup/config-s3.yml "+envArgs+" watch --watch-interval=10s --full-interval=1h --watch-backup-name-template="+prefix+"-{type}-{time:20060102150405} --tables="+dbName+".* &>>/tmp/watch_env_override.log")
51+
defer func() {
52+
// [c]lickhouse regexp bracket trick, so pkill doesn't match its own `bash -ce` command line and kill itself with SIGTERM;
53+
// wait until the watch process actually exits, a backup in flight during pkill can re-create local files after cleanup
54+
env.DockerExecNoError(r, "clickhouse-backup", "bash", "-ce", "pkill -f '[c]lickhouse-backup.*watch' || true; for i in $(seq 1 30); do pgrep -f '[c]lickhouse-backup.*watch' >/dev/null || break; sleep 1; done")
55+
out, _ := env.DockerExecOut("clickhouse-backup", "bash", "-ce", "cat /tmp/watch_env_override.log; rm -f /tmp/watch_env_override.log")
56+
log.Debug().Msg(out)
57+
}()
58+
59+
// the second backup proves the config reload inside the watch loop keeps --env too, not only the initial load
60+
var matched []string
61+
deadline := time.Now().Add(5 * time.Minute)
62+
for time.Now().Before(deadline) {
63+
if matched = listMatched(envArgs); len(matched) >= 2 {
64+
break
65+
}
66+
time.Sleep(5 * time.Second)
67+
}
68+
r.GreaterOrEqual(len(matched), 2, "every watch iteration shall upload into S3_PATH from --env, got %v", matched)
69+
r.Empty(listMatched(""), "watch shall not upload into s3->path from the config file when --env S3_PATH is passed")
70+
}

0 commit comments

Comments
 (0)