diff --git a/README.md b/README.md index ba3e9e6..d913d28 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,22 @@ Check out these projects for more detailed examples of Windows-centric Packer te - [joefitzgerald/packer-windows](https://github.com/joefitzgerald/packer-windows) - [box-cutter/windows-vm](https://github.com/box-cutter/windows-vm) +### Running commands with Elevated privileges + +In certain situations, you may need to run commands with elevated privileges even if your `winrm_username` user is an Administrator, for example upgrading system packages like the .NET Framework. +In these cases there are 2 additional keys provided on the `powershell` provisioner that you can supply that will enable this mode: `elevated_user` and `elevated_password`: + +``` +{ + "type": "powershell", + "elevated_user":"vagrant", + "elevated_password":"vagrant", + "inline": [ + "choco install netfx-4.5.2-devpack" + ] +} +``` + ### Community - **IRC**: `#packer-community` on Freenode. - **Slack**: packer.slack.com diff --git a/communicator/winrm/communicator.go b/communicator/winrm/communicator.go index 0e99cce..f64003c 100644 --- a/communicator/winrm/communicator.go +++ b/communicator/winrm/communicator.go @@ -18,13 +18,6 @@ type Communicator struct { endpoint *winrm.Endpoint user string password string - timeout time.Duration -} - -type elevatedShellOptions struct { - Command string - User string - Password string } // Creates a new packer.Communicator implementation over WinRM. @@ -56,15 +49,7 @@ func New(endpoint *winrm.Endpoint, user string, password string, timeout time.Du }, nil } -func (c *Communicator) Start(rc *packer.RemoteCmd) (err error) { - return c.runCommand(rc, rc.Command) -} - -func (c *Communicator) StartElevated(cmd *packer.RemoteCmd) (err error) { - panic("not implemented") -} - -func (c *Communicator) runCommand(rc *packer.RemoteCmd, command string, arguments ...string) (err error) { +func (c *Communicator) Start(rc *packer.RemoteCmd) error { log.Printf("starting remote command: %s", rc.Command) // Create a new shell process on the guest @@ -80,22 +65,23 @@ func (c *Communicator) runCommand(rc *packer.RemoteCmd, command string, argument return err } - cmd, err := shell.Execute(command, arguments...) + cmd, err := shell.Execute(rc.Command) if err != nil { return err } - go func(shell *winrm.Shell, cmd *winrm.Command, rc *packer.RemoteCmd) { - defer shell.Close() + go runCommand(shell, cmd, rc) + return nil +} - go io.Copy(rc.Stdout, cmd.Stdout) - go io.Copy(rc.Stderr, cmd.Stderr) +func runCommand(shell *winrm.Shell, cmd *winrm.Command, rc *packer.RemoteCmd) { + defer shell.Close() - cmd.Wait() - rc.SetExited(cmd.ExitCode()) - }(shell, cmd, rc) + go io.Copy(rc.Stdout, cmd.Stdout) + go io.Copy(rc.Stderr, cmd.Stderr) - return nil + cmd.Wait() + rc.SetExited(cmd.ExitCode()) } func (c *Communicator) Upload(dst string, input io.Reader, ignored *os.FileInfo) error { @@ -125,6 +111,7 @@ func (c *Communicator) newCopyClient() (*winrmcp.Winrmcp, error) { User: c.user, Password: c.password, }, + OperationTimeout: time.Minute * 5, MaxOperationsPerShell: 15, // lowest common denominator }) } diff --git a/communicator/winrm/communicator_test.go b/communicator/winrm/communicator_test.go index 4261214..ed1b904 100644 --- a/communicator/winrm/communicator_test.go +++ b/communicator/winrm/communicator_test.go @@ -54,41 +54,6 @@ func TestStart(t *testing.T) { } } -func TestStartElevated(t *testing.T) { - // This test hits an already running Windows VM - // You can comment this line out temporarily during development - t.Skip() - - comm, err := New(&winrm.Endpoint{"localhost", 5985}, "vagrant", "vagrant", time.Duration(1)*time.Minute) - if err != nil { - t.Fatalf("error connecting to WinRM: %s", err) - } - - var cmd packer.RemoteCmd - var outWriter, errWriter bytes.Buffer - - cmd.Command = "dir" - cmd.Stdout = &outWriter - cmd.Stderr = &errWriter - - err = comm.StartElevated(&cmd) - if err != nil { - t.Fatalf("error starting cmd: %s", err) - } - cmd.Wait() - - fmt.Println(outWriter.String()) - fmt.Println(errWriter.String()) - - if err != nil { - t.Fatalf("error running cmd: %s", err) - } - - if cmd.ExitStatus != 0 { - t.Fatalf("exit status was non-zero: %d", cmd.ExitStatus) - } -} - func TestUpload(t *testing.T) { // This test hits an already running Windows VM // You can comment this line out temporarily during development diff --git a/provisioner/powershell/elevated.go b/provisioner/powershell/elevated.go new file mode 100644 index 0000000..b428488 --- /dev/null +++ b/provisioner/powershell/elevated.go @@ -0,0 +1,87 @@ +package powershell + +import ( + "text/template" +) + +type elevatedOptions struct { + User string + Password string + TaskName string + TaskDescription string + EncodedCommand string +} + +var elevatedTemplate = template.Must(template.New("ElevatedCommand").Parse(` +$name = "{{.TaskName}}" +$log = "$env:TEMP\$name.out" +$s = New-Object -ComObject "Schedule.Service" +$s.Connect() +$t = $s.NewTask($null) +$t.XmlText = @' + + + + {{.TaskDescription}} + + + + {{.User}} + Password + HighestAvailable + + + + IgnoreNew + false + false + true + false + false + + true + false + + true + true + false + false + false + PT2H + 4 + + + + cmd + /c powershell.exe -EncodedCommand {{.EncodedCommand}} > %TEMP%\{{.TaskName}}.out 2>&1 + + + +'@ +$f = $s.GetFolder("\") +$f.RegisterTaskDefinition($name, $t, 6, "{{.User}}", "{{.Password}}", 1, $null) | Out-Null +$t = $f.GetTask("\$name") +$t.Run($null) | Out-Null +$timeout = 10 +$sec = 0 +while ((!($t.state -eq 4)) -and ($sec -lt $timeout)) { + Start-Sleep -s 1 + $sec++ +} +function SlurpOutput($l) { + if (Test-Path $log) { + Get-Content $log | select -skip $l | ForEach { + $l += 1 + Write-Host "$_" + } + } + return $l +} +$line = 0 +do { + Start-Sleep -m 100 + $line = SlurpOutput $line +} while (!($t.state -eq 3)) +$result = $t.LastTaskResult +[System.Runtime.Interopservices.Marshal]::ReleaseComObject($s) | Out-Null +exit $result`)) diff --git a/provisioner/powershell/powershell.go b/provisioner/powershell/powershell.go new file mode 100644 index 0000000..1f5a7ff --- /dev/null +++ b/provisioner/powershell/powershell.go @@ -0,0 +1,17 @@ +package powershell + +import ( + "encoding/base64" +) + +func powershellEncode(buffer []byte) string { + // 2 byte chars to make PowerShell happy + wideCmd := "" + for _, b := range buffer { + wideCmd += string(b) + "\x00" + } + + // Base64 encode the command + input := []uint8(wideCmd) + return base64.StdEncoding.EncodeToString(input) +} diff --git a/provisioner/powershell/provisioner.go b/provisioner/powershell/provisioner.go index 5cc85c0..0e7b0e5 100644 --- a/provisioner/powershell/provisioner.go +++ b/provisioner/powershell/provisioner.go @@ -4,16 +4,19 @@ package powershell import ( "bufio" + "bytes" "errors" "fmt" - "github.com/mitchellh/packer/common" - "github.com/mitchellh/packer/packer" "io/ioutil" "log" "os" "sort" "strings" "time" + + "github.com/mitchellh/packer/common" + "github.com/mitchellh/packer/common/uuid" + "github.com/mitchellh/packer/packer" ) const DefaultRemotePath = "c:/Windows/Temp/script.ps1" @@ -50,6 +53,11 @@ type config struct { // can be used to inject the environment_vars into the environment. ExecuteCommand string `mapstructure:"execute_command"` + // The command used to execute the elevated script. The '{{ .Path }}' variable + // should be used to specify where the script goes, {{ .Vars }} + // can be used to inject the environment_vars into the environment. + ElevatedExecuteCommand string `mapstructure:"elevated_execute_command"` + // The timeout for retrying to start the process. Until this timeout // is reached, if the provisioner can't start a process, it retries. // This can be set high to allow for reboots. @@ -59,12 +67,23 @@ type config struct { // inside the `ExecuteCommand` template. EnvVarFormat string + // This is used in the template generation to format environment variables + // inside the `ElevatedExecuteCommand` template. + ElevatedEnvVarFormat string `mapstructure:"elevated_env_var_format"` + + // Instructs the communicator to run the remote script as a + // Windows scheduled task, effectively elevating the remote + // user by impersonating a logged-in user + ElevatedUser string `mapstructure:"elevated_user"` + ElevatedPassword string `mapstructure:"elevated_password"` + startRetryTimeout time.Duration tpl *packer.ConfigTemplate } type Provisioner struct { - config config + config config + communicator packer.Communicator } type ExecuteCommandTemplate struct { @@ -91,10 +110,18 @@ func (p *Provisioner) Prepare(raws ...interface{}) error { p.config.EnvVarFormat = `$env:%s=\"%s\"; ` } + if p.config.ElevatedEnvVarFormat == "" { + p.config.ElevatedEnvVarFormat = `$env:%s="%s"; ` + } + if p.config.ExecuteCommand == "" { p.config.ExecuteCommand = `powershell "& { {{.Vars}}{{.Path}} }"` } + if p.config.ElevatedExecuteCommand == "" { + p.config.ElevatedExecuteCommand = `{{.Vars}}{{.Path}}` + } + if p.config.Inline != nil && len(p.config.Inline) == 0 { p.config.Inline = nil } @@ -120,6 +147,16 @@ func (p *Provisioner) Prepare(raws ...interface{}) error { errors.New("Only one of script or scripts can be specified.")) } + if p.config.ElevatedUser != "" && p.config.ElevatedPassword == "" { + errs = packer.MultiErrorAppend(errs, + errors.New("Must supply an 'elevated_password' if 'elevated_user' provided")) + } + + if p.config.ElevatedUser == "" && p.config.ElevatedPassword != "" { + errs = packer.MultiErrorAppend(errs, + errors.New("Must supply an 'elevated_user' if 'elevated_password' provided")) + } + if p.config.Script != "" { p.config.Scripts = []string{p.config.Script} } @@ -201,9 +238,9 @@ func (p *Provisioner) Prepare(raws ...interface{}) error { func extractScript(p *Provisioner) (string, error) { temp, err := ioutil.TempFile(os.TempDir(), "packer-powershell-provisioner") if err != nil { - log.Printf("Unable to create temporary file for inline scripts: %s", err) return "", err } + defer temp.Close() writer := bufio.NewWriter(temp) for _, command := range p.config.Inline { log.Printf("Found command: %s", command) @@ -216,13 +253,12 @@ func extractScript(p *Provisioner) (string, error) { return "", fmt.Errorf("Error preparing shell script: %s", err) } - temp.Close() - return temp.Name(), nil } func (p *Provisioner) Provision(ui packer.Ui, comm packer.Communicator) error { ui.Say(fmt.Sprintf("Provisioning with Powershell...")) + p.communicator = comm scripts := make([]string, len(p.config.Scripts)) copy(scripts, p.config.Scripts) @@ -251,17 +287,7 @@ func (p *Provisioner) Provision(ui packer.Ui, comm packer.Communicator) error { } defer f.Close() - // Create environment variables to set before executing the command - flattendVars, err := p.createFlattenedEnvVars() - if err != nil { - return err - } - - // Compile the command - command, err := p.config.tpl.Process(p.config.ExecuteCommand, &ExecuteCommandTemplate{ - Vars: flattendVars, - Path: p.config.RemotePath, - }) + command, err := p.createCommandText() if err != nil { return fmt.Errorf("Error processing command: %s", err) } @@ -331,7 +357,7 @@ func (p *Provisioner) retryable(f func() error) error { } } -func (p *Provisioner) createFlattenedEnvVars() (flattened string, err error) { +func (p *Provisioner) createFlattenedEnvVars(elevated bool) (flattened string, err error) { flattened = "" envVars := make(map[string]string) @@ -348,15 +374,105 @@ func (p *Provisioner) createFlattenedEnvVars() (flattened string, err error) { } envVars[keyValue[0]] = keyValue[1] } + // Create a list of env var keys in sorted order var keys []string for k := range envVars { keys = append(keys, k) } sort.Strings(keys) + format := p.config.EnvVarFormat + if elevated { + format = p.config.ElevatedEnvVarFormat + } + // Re-assemble vars using OS specific format pattern and flatten for _, key := range keys { - flattened += fmt.Sprintf(p.config.EnvVarFormat, key, envVars[key]) + flattened += fmt.Sprintf(format, key, envVars[key]) + } + return +} + +func (p *Provisioner) createCommandText() (command string, err error) { + // Create environment variables to set before executing the command + flattenedEnvVars, err := p.createFlattenedEnvVars(false) + if err != nil { + return "", err + } + command, err = p.config.tpl.Process(p.config.ExecuteCommand, &ExecuteCommandTemplate{ + Vars: flattenedEnvVars, + Path: p.config.RemotePath, + }) + + if err != nil { + return "", err } + + // Return the interpolated command + if p.config.ElevatedUser == "" { + return command, nil + } + + // Can't double escape the env vars, lets create shiny new ones + flattenedEnvVars, err = p.createFlattenedEnvVars(true) + command, err = p.config.tpl.Process(p.config.ExecuteCommand, &ExecuteCommandTemplate{ + Vars: flattenedEnvVars, + Path: p.config.RemotePath, + }) + + // OK so we need an elevated shell runner to wrap our command, this is going to have its own path + // generate the script and update the command runner in the process + path, err := p.generateElevatedRunner(command) + + // Return the path to the elevated shell wrapper + command = fmt.Sprintf("powershell -executionpolicy bypass -file \"%s\"", path) + return } + +func (p *Provisioner) generateElevatedRunner(command string) (uploadedPath string, err error) { + log.Printf("Building elevated command wrapper for: %s", command) + + // generate command + var buffer bytes.Buffer + err = elevatedTemplate.Execute(&buffer, elevatedOptions{ + User: p.config.ElevatedUser, + Password: p.config.ElevatedPassword, + TaskDescription: "Packer elevated task", + TaskName: fmt.Sprintf("packer-%s", uuid.TimeOrderedUUID()), + EncodedCommand: powershellEncode([]byte(command + "; exit $LASTEXITCODE")), + }) + + if err != nil { + fmt.Printf("Error creating elevated template: %s", err) + return "", err + } + + tmpFile, err := ioutil.TempFile(os.TempDir(), "packer-elevated-shell.ps1") + writer := bufio.NewWriter(tmpFile) + if _, err := writer.WriteString(string(buffer.Bytes())); err != nil { + return "", fmt.Errorf("Error preparing elevated shell script: %s", err) + } + + if err := writer.Flush(); err != nil { + return "", fmt.Errorf("Error preparing elevated shell script: %s", err) + } + tmpFile.Close() + f, err := os.Open(tmpFile.Name()) + if err != nil { + return "", fmt.Errorf("Error opening temporary elevated shell script: %s", err) + } + defer f.Close() + + uuid := uuid.TimeOrderedUUID() + path := fmt.Sprintf(`${env:TEMP}\packer-elevated-shell-%s.ps1`, uuid) + log.Printf("Uploading elevated shell wrapper for command [%s] to [%s] from [%s]", command, path, tmpFile.Name()) + err = p.communicator.Upload(path, f, nil) + if err != nil { + return "", fmt.Errorf("Error preparing elevated shell script: %s", err) + } + + // CMD formatted Path required for this op + path = fmt.Sprintf("%s-%s.ps1", "%TEMP%\\packer-elevated-shell", uuid) + return path, err +} diff --git a/provisioner/powershell/provisioner_test.go b/provisioner/powershell/provisioner_test.go index 3c6902d..55df40a 100644 --- a/provisioner/powershell/provisioner_test.go +++ b/provisioner/powershell/provisioner_test.go @@ -4,12 +4,15 @@ import ( "bytes" "errors" "fmt" - "github.com/mitchellh/packer/packer" "io/ioutil" + //"log" "os" + "regexp" "strings" "testing" "time" + + "github.com/mitchellh/packer/packer" ) func testConfig() map[string]interface{} { @@ -18,6 +21,10 @@ func testConfig() map[string]interface{} { } } +func init() { + //log.SetOutput(ioutil.Discard) +} + func TestProvisionerPrepare_extractScript(t *testing.T) { config := testConfig() p := new(Provisioner) @@ -61,9 +68,24 @@ func TestProvisionerPrepare_Defaults(t *testing.T) { t.Errorf("unexpected remote path: %s", p.config.RemotePath) } + if p.config.ElevatedUser != "" { + t.Error("expected elevated_user to be empty") + } + if p.config.ElevatedPassword != "" { + t.Error("expected elevated_password to be empty") + } + if p.config.ExecuteCommand != "powershell \"& { {{.Vars}}{{.Path}} }\"" { t.Fatalf("Default command should be powershell \"& { {{.Vars}}{{.Path}} }\", but got %s", p.config.ExecuteCommand) } + + if p.config.ElevatedExecuteCommand != "{{.Vars}}{{.Path}}" { + t.Fatalf("Default command should be powershell {{.Vars}}{{.Path}}, but got %s", p.config.ElevatedExecuteCommand) + } + + if p.config.ElevatedEnvVarFormat != `$env:%s="%s"; ` { + t.Fatalf("Default command should be powershell \"{{.Vars}}{{.Path}}\", but got %s", p.config.ElevatedEnvVarFormat) + } } func TestProvisionerPrepare_Config(t *testing.T) { @@ -82,6 +104,26 @@ func TestProvisionerPrepare_InvalidKey(t *testing.T) { } } +func TestProvisionerPrepare_Elevated(t *testing.T) { + var p Provisioner + config := testConfig() + + // Add a random key + config["elevated_user"] = "vagrant" + err := p.Prepare(config) + + if err == nil { + t.Fatal("should have error (only provided elevated_user)") + } + + config["elevated_password"] = "vagrant" + err = p.Prepare(config) + + if err != nil { + t.Fatal("should not have error") + } +} + func TestProvisionerPrepare_Script(t *testing.T) { config := testConfig() delete(config, "inline") @@ -295,43 +337,6 @@ func TestProvisionerProvision_Inline(t *testing.T) { t.Fatalf("Expect command to be: %s, got: %s", expectedCommand, comm.StartCmd.Command) } } -func xTestProvisionerProvision_Inline(t *testing.T) { - config := testConfig() - delete(config, "inline") - config["scripts"] = []string{} - config["inline"] = "powershell -command Foo-Command" - ui := testUi() - - p := new(Provisioner) - comm := new(packer.MockCommunicator) - p.Prepare(config) - err := p.Provision(ui, comm) - if err != nil { - t.Fatal("should not have error") - } - - // Should run the command without alteration - if comm.StartCmd.Command != config["inline"] { - t.Fatalf("Expect command not to be: %s", comm.StartCmd.Command) - } - - // Env vars - currently should not effect them - envVars := make([]string, 2) - envVars[0] = "FOO=BAR" - envVars[1] = "BAR=BAZ" - config["Vars"] = envVars - - p.Prepare(config) - err = p.Provision(ui, comm) - if err != nil { - t.Fatal("should not have error") - } - - // Should run the command without alteration - if comm.StartCmd.Command != config["inline"] { - t.Fatalf("Expect command not to be: %s", comm.StartCmd.Command) - } -} func TestProvisionerProvision_Scripts(t *testing.T) { tempFile, _ := ioutil.TempFile("", "packer") @@ -399,6 +404,51 @@ func TestProvisionerProvision_UISlurp(t *testing.T) { // UI should receive following messages / output } +func TestProvisioner_createFlattenedElevatedEnvVars_windows(t *testing.T) { + config := testConfig() + + p := new(Provisioner) + err := p.Prepare(config) + if err != nil { + t.Fatalf("should not have error preparing config: %s", err) + } + + // Defaults provided by Packer + p.config.PackerBuildName = "vmware" + p.config.PackerBuilderType = "iso" + + // no user env var + flattenedEnvVars, err := p.createFlattenedEnvVars(true) + if err != nil { + t.Fatalf("should not have error creating flattened env vars: %s", err) + } + if flattenedEnvVars != "$env:PACKER_BUILDER_TYPE=\"iso\"; $env:PACKER_BUILD_NAME=\"vmware\"; " { + t.Fatalf("unexpected flattened env vars: %s", flattenedEnvVars) + } + + // single user env var + p.config.Vars = []string{"FOO=bar"} + + flattenedEnvVars, err = p.createFlattenedEnvVars(true) + if err != nil { + t.Fatalf("should not have error creating flattened env vars: %s", err) + } + if flattenedEnvVars != "$env:FOO=\"bar\"; $env:PACKER_BUILDER_TYPE=\"iso\"; $env:PACKER_BUILD_NAME=\"vmware\"; " { + t.Fatalf("unexpected flattened env vars: %s", flattenedEnvVars) + } + + // multiple user env vars + p.config.Vars = []string{"FOO=bar", "BAZ=qux"} + + flattenedEnvVars, err = p.createFlattenedEnvVars(true) + if err != nil { + t.Fatalf("should not have error creating flattened env vars: %s", err) + } + if flattenedEnvVars != "$env:BAZ=\"qux\"; $env:FOO=\"bar\"; $env:PACKER_BUILDER_TYPE=\"iso\"; $env:PACKER_BUILD_NAME=\"vmware\"; " { + t.Fatalf("unexpected flattened env vars: %s", flattenedEnvVars) + } +} + func TestProvisioner_createFlattenedEnvVars_windows(t *testing.T) { config := testConfig() @@ -413,7 +463,7 @@ func TestProvisioner_createFlattenedEnvVars_windows(t *testing.T) { p.config.PackerBuilderType = "iso" // no user env var - flattenedEnvVars, err := p.createFlattenedEnvVars() + flattenedEnvVars, err := p.createFlattenedEnvVars(false) if err != nil { t.Fatalf("should not have error creating flattened env vars: %s", err) } @@ -424,7 +474,7 @@ func TestProvisioner_createFlattenedEnvVars_windows(t *testing.T) { // single user env var p.config.Vars = []string{"FOO=bar"} - flattenedEnvVars, err = p.createFlattenedEnvVars() + flattenedEnvVars, err = p.createFlattenedEnvVars(false) if err != nil { t.Fatalf("should not have error creating flattened env vars: %s", err) } @@ -435,7 +485,7 @@ func TestProvisioner_createFlattenedEnvVars_windows(t *testing.T) { // multiple user env vars p.config.Vars = []string{"FOO=bar", "BAZ=qux"} - flattenedEnvVars, err = p.createFlattenedEnvVars() + flattenedEnvVars, err = p.createFlattenedEnvVars(false) if err != nil { t.Fatalf("should not have error creating flattened env vars: %s", err) } @@ -444,6 +494,54 @@ func TestProvisioner_createFlattenedEnvVars_windows(t *testing.T) { } } +func TestProvision_createCommandText(t *testing.T) { + + config := testConfig() + p := new(Provisioner) + comm := new(packer.MockCommunicator) + p.communicator = comm + _ = p.Prepare(config) + + // Non-elevated + cmd, _ := p.createCommandText() + if cmd != "powershell \"& { $env:PACKER_BUILDER_TYPE=\\\"\\\"; $env:PACKER_BUILD_NAME=\\\"\\\"; c:/Windows/Temp/script.ps1 }\"" { + t.Fatalf("Got unexpected non-elevated command: %s", cmd) + } + + // Elevated + p.config.ElevatedUser = "vagrant" + p.config.ElevatedPassword = "vagrant" + cmd, _ = p.createCommandText() + matched, _ := regexp.MatchString("powershell -executionpolicy bypass -file \"%TEMP%(.{1})packer-elevated-shell.*", cmd) + if !matched { + t.Fatalf("Got unexpected elevated command: %s", cmd) + } +} + +func TestProvision_generateElevatedShellRunner(t *testing.T) { + + // Non-elevated + config := testConfig() + p := new(Provisioner) + p.Prepare(config) + comm := new(packer.MockCommunicator) + p.communicator = comm + path, err := p.generateElevatedRunner("whoami") + + if err != nil { + t.Fatalf("Did not expect error: %s", err.Error()) + } + + if comm.UploadCalled != true { + t.Fatalf("Should have uploaded file") + } + + matched, _ := regexp.MatchString("%TEMP%(.{1})packer-elevated-shell.*", path) + if !matched { + t.Fatalf("Got unexpected file: %s", path) + } +} + func TestRetryable(t *testing.T) { config := testConfig()