backend/local: Replace CLI with view instance
This commit extracts the remaining UI logic from the local backend,
and removes access to the direct CLI output. This is replaced with an
instance of a `views.Operation` interface, which codifies the current
requirements for the local backend to interact with the user.
The exception to this at present is interactivity: approving a plan
still depends on the `UIIn` field for the backend. This is out of scope
for this commit and can be revisited separately, at which time the
`UIOut` field can also be removed.
Changes in support of this:
- Some instances of direct error output have been replaced with
diagnostics, most notably in the emergency state backup handler. This
requires reformatting the error messages to allow the diagnostic
renderer to line-wrap them;
- The "in-automation" logic has moved out of the backend and into the
view implementation;
- The plan, apply, refresh, and import commands instantiate a view and
set it on the `backend.Operation` struct, as these are the only code
paths which call the `local.Operation()` method that requires it;
- The show command requires the plan rendering code which is now in the
views package, so there is a stub implementation of a `views.Show`
interface there.
Other refactoring work in support of migrating these commands to the
common views code structure will come in follow-up PRs, at which point
we will be able to remove the UI instances from the unit tests for those
commands.
2021-02-17 19:01:30 +01:00
|
|
|
package views
|
|
|
|
|
|
|
|
import (
|
2021-02-23 16:16:09 +01:00
|
|
|
"bytes"
|
backend/local: Replace CLI with view instance
This commit extracts the remaining UI logic from the local backend,
and removes access to the direct CLI output. This is replaced with an
instance of a `views.Operation` interface, which codifies the current
requirements for the local backend to interact with the user.
The exception to this at present is interactivity: approving a plan
still depends on the `UIIn` field for the backend. This is out of scope
for this commit and can be revisited separately, at which time the
`UIOut` field can also be removed.
Changes in support of this:
- Some instances of direct error output have been replaced with
diagnostics, most notably in the emergency state backup handler. This
requires reformatting the error messages to allow the diagnostic
renderer to line-wrap them;
- The "in-automation" logic has moved out of the backend and into the
view implementation;
- The plan, apply, refresh, and import commands instantiate a view and
set it on the `backend.Operation` struct, as these are the only code
paths which call the `local.Operation()` method that requires it;
- The show command requires the plan rendering code which is now in the
views package, so there is a stub implementation of a `views.Show`
interface there.
Other refactoring work in support of migrating these commands to the
common views code structure will come in follow-up PRs, at which point
we will be able to remove the UI instances from the unit tests for those
commands.
2021-02-17 19:01:30 +01:00
|
|
|
"encoding/json"
|
|
|
|
"strings"
|
|
|
|
"testing"
|
|
|
|
|
2021-05-17 21:00:50 +02:00
|
|
|
"github.com/hashicorp/terraform/internal/addrs"
|
2021-05-17 21:07:38 +02:00
|
|
|
"github.com/hashicorp/terraform/internal/command/arguments"
|
2021-05-17 21:33:17 +02:00
|
|
|
"github.com/hashicorp/terraform/internal/plans"
|
2021-05-17 21:43:35 +02:00
|
|
|
"github.com/hashicorp/terraform/internal/states"
|
|
|
|
"github.com/hashicorp/terraform/internal/states/statefile"
|
backend/local: Replace CLI with view instance
This commit extracts the remaining UI logic from the local backend,
and removes access to the direct CLI output. This is replaced with an
instance of a `views.Operation` interface, which codifies the current
requirements for the local backend to interact with the user.
The exception to this at present is interactivity: approving a plan
still depends on the `UIIn` field for the backend. This is out of scope
for this commit and can be revisited separately, at which time the
`UIOut` field can also be removed.
Changes in support of this:
- Some instances of direct error output have been replaced with
diagnostics, most notably in the emergency state backup handler. This
requires reformatting the error messages to allow the diagnostic
renderer to line-wrap them;
- The "in-automation" logic has moved out of the backend and into the
view implementation;
- The plan, apply, refresh, and import commands instantiate a view and
set it on the `backend.Operation` struct, as these are the only code
paths which call the `local.Operation()` method that requires it;
- The show command requires the plan rendering code which is now in the
views package, so there is a stub implementation of a `views.Show`
interface there.
Other refactoring work in support of migrating these commands to the
common views code structure will come in follow-up PRs, at which point
we will be able to remove the UI instances from the unit tests for those
commands.
2021-02-17 19:01:30 +01:00
|
|
|
"github.com/hashicorp/terraform/internal/terminal"
|
2021-05-17 21:46:19 +02:00
|
|
|
"github.com/hashicorp/terraform/internal/terraform"
|
backend/local: Replace CLI with view instance
This commit extracts the remaining UI logic from the local backend,
and removes access to the direct CLI output. This is replaced with an
instance of a `views.Operation` interface, which codifies the current
requirements for the local backend to interact with the user.
The exception to this at present is interactivity: approving a plan
still depends on the `UIIn` field for the backend. This is out of scope
for this commit and can be revisited separately, at which time the
`UIOut` field can also be removed.
Changes in support of this:
- Some instances of direct error output have been replaced with
diagnostics, most notably in the emergency state backup handler. This
requires reformatting the error messages to allow the diagnostic
renderer to line-wrap them;
- The "in-automation" logic has moved out of the backend and into the
view implementation;
- The plan, apply, refresh, and import commands instantiate a view and
set it on the `backend.Operation` struct, as these are the only code
paths which call the `local.Operation()` method that requires it;
- The show command requires the plan rendering code which is now in the
views package, so there is a stub implementation of a `views.Show`
interface there.
Other refactoring work in support of migrating these commands to the
common views code structure will come in follow-up PRs, at which point
we will be able to remove the UI instances from the unit tests for those
commands.
2021-02-17 19:01:30 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
func TestOperation_stopping(t *testing.T) {
|
|
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
|
|
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
|
|
|
|
|
|
|
|
v.Stopping()
|
|
|
|
|
|
|
|
if got, want := done(t).Stdout(), "Stopping operation...\n"; got != want {
|
|
|
|
t.Errorf("wrong result\ngot: %q\nwant: %q", got, want)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestOperation_cancelled(t *testing.T) {
|
|
|
|
testCases := map[string]struct {
|
2021-04-06 01:28:59 +02:00
|
|
|
planMode plans.Mode
|
|
|
|
want string
|
backend/local: Replace CLI with view instance
This commit extracts the remaining UI logic from the local backend,
and removes access to the direct CLI output. This is replaced with an
instance of a `views.Operation` interface, which codifies the current
requirements for the local backend to interact with the user.
The exception to this at present is interactivity: approving a plan
still depends on the `UIIn` field for the backend. This is out of scope
for this commit and can be revisited separately, at which time the
`UIOut` field can also be removed.
Changes in support of this:
- Some instances of direct error output have been replaced with
diagnostics, most notably in the emergency state backup handler. This
requires reformatting the error messages to allow the diagnostic
renderer to line-wrap them;
- The "in-automation" logic has moved out of the backend and into the
view implementation;
- The plan, apply, refresh, and import commands instantiate a view and
set it on the `backend.Operation` struct, as these are the only code
paths which call the `local.Operation()` method that requires it;
- The show command requires the plan rendering code which is now in the
views package, so there is a stub implementation of a `views.Show`
interface there.
Other refactoring work in support of migrating these commands to the
common views code structure will come in follow-up PRs, at which point
we will be able to remove the UI instances from the unit tests for those
commands.
2021-02-17 19:01:30 +01:00
|
|
|
}{
|
|
|
|
"apply": {
|
2021-04-06 01:28:59 +02:00
|
|
|
planMode: plans.NormalMode,
|
|
|
|
want: "Apply cancelled.\n",
|
backend/local: Replace CLI with view instance
This commit extracts the remaining UI logic from the local backend,
and removes access to the direct CLI output. This is replaced with an
instance of a `views.Operation` interface, which codifies the current
requirements for the local backend to interact with the user.
The exception to this at present is interactivity: approving a plan
still depends on the `UIIn` field for the backend. This is out of scope
for this commit and can be revisited separately, at which time the
`UIOut` field can also be removed.
Changes in support of this:
- Some instances of direct error output have been replaced with
diagnostics, most notably in the emergency state backup handler. This
requires reformatting the error messages to allow the diagnostic
renderer to line-wrap them;
- The "in-automation" logic has moved out of the backend and into the
view implementation;
- The plan, apply, refresh, and import commands instantiate a view and
set it on the `backend.Operation` struct, as these are the only code
paths which call the `local.Operation()` method that requires it;
- The show command requires the plan rendering code which is now in the
views package, so there is a stub implementation of a `views.Show`
interface there.
Other refactoring work in support of migrating these commands to the
common views code structure will come in follow-up PRs, at which point
we will be able to remove the UI instances from the unit tests for those
commands.
2021-02-17 19:01:30 +01:00
|
|
|
},
|
|
|
|
"destroy": {
|
2021-04-06 01:28:59 +02:00
|
|
|
planMode: plans.DestroyMode,
|
|
|
|
want: "Destroy cancelled.\n",
|
backend/local: Replace CLI with view instance
This commit extracts the remaining UI logic from the local backend,
and removes access to the direct CLI output. This is replaced with an
instance of a `views.Operation` interface, which codifies the current
requirements for the local backend to interact with the user.
The exception to this at present is interactivity: approving a plan
still depends on the `UIIn` field for the backend. This is out of scope
for this commit and can be revisited separately, at which time the
`UIOut` field can also be removed.
Changes in support of this:
- Some instances of direct error output have been replaced with
diagnostics, most notably in the emergency state backup handler. This
requires reformatting the error messages to allow the diagnostic
renderer to line-wrap them;
- The "in-automation" logic has moved out of the backend and into the
view implementation;
- The plan, apply, refresh, and import commands instantiate a view and
set it on the `backend.Operation` struct, as these are the only code
paths which call the `local.Operation()` method that requires it;
- The show command requires the plan rendering code which is now in the
views package, so there is a stub implementation of a `views.Show`
interface there.
Other refactoring work in support of migrating these commands to the
common views code structure will come in follow-up PRs, at which point
we will be able to remove the UI instances from the unit tests for those
commands.
2021-02-17 19:01:30 +01:00
|
|
|
},
|
|
|
|
}
|
|
|
|
for name, tc := range testCases {
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
|
|
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
|
|
|
|
|
2021-04-06 01:28:59 +02:00
|
|
|
v.Cancelled(tc.planMode)
|
backend/local: Replace CLI with view instance
This commit extracts the remaining UI logic from the local backend,
and removes access to the direct CLI output. This is replaced with an
instance of a `views.Operation` interface, which codifies the current
requirements for the local backend to interact with the user.
The exception to this at present is interactivity: approving a plan
still depends on the `UIIn` field for the backend. This is out of scope
for this commit and can be revisited separately, at which time the
`UIOut` field can also be removed.
Changes in support of this:
- Some instances of direct error output have been replaced with
diagnostics, most notably in the emergency state backup handler. This
requires reformatting the error messages to allow the diagnostic
renderer to line-wrap them;
- The "in-automation" logic has moved out of the backend and into the
view implementation;
- The plan, apply, refresh, and import commands instantiate a view and
set it on the `backend.Operation` struct, as these are the only code
paths which call the `local.Operation()` method that requires it;
- The show command requires the plan rendering code which is now in the
views package, so there is a stub implementation of a `views.Show`
interface there.
Other refactoring work in support of migrating these commands to the
common views code structure will come in follow-up PRs, at which point
we will be able to remove the UI instances from the unit tests for those
commands.
2021-02-17 19:01:30 +01:00
|
|
|
|
|
|
|
if got, want := done(t).Stdout(), tc.want; got != want {
|
|
|
|
t.Errorf("wrong result\ngot: %q\nwant: %q", got, want)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestOperation_emergencyDumpState(t *testing.T) {
|
|
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
|
|
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
|
|
|
|
|
|
|
|
stateFile := statefile.New(nil, "foo", 1)
|
|
|
|
|
|
|
|
err := v.EmergencyDumpState(stateFile)
|
|
|
|
if err != nil {
|
|
|
|
t.Fatalf("unexpected error dumping state: %s", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check that the result (on stderr) looks like JSON state
|
|
|
|
raw := done(t).Stderr()
|
|
|
|
var state map[string]interface{}
|
|
|
|
if err := json.Unmarshal([]byte(raw), &state); err != nil {
|
|
|
|
t.Fatalf("unexpected error parsing dumped state: %s\nraw:\n%s", err, raw)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestOperation_planNoChanges(t *testing.T) {
|
|
|
|
|
2021-05-07 00:22:48 +02:00
|
|
|
tests := map[string]struct {
|
|
|
|
plan func(schemas *terraform.Schemas) *plans.Plan
|
|
|
|
wantText string
|
|
|
|
}{
|
|
|
|
"nothing at all in normal mode": {
|
|
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
|
|
return &plans.Plan{
|
|
|
|
UIMode: plans.NormalMode,
|
|
|
|
Changes: plans.NewChanges(),
|
|
|
|
PrevRunState: states.NewState(),
|
|
|
|
PriorState: states.NewState(),
|
|
|
|
}
|
|
|
|
},
|
|
|
|
"no differences, so no changes are needed.",
|
|
|
|
},
|
|
|
|
"nothing at all in refresh-only mode": {
|
|
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
|
|
return &plans.Plan{
|
|
|
|
UIMode: plans.RefreshOnlyMode,
|
|
|
|
Changes: plans.NewChanges(),
|
|
|
|
PrevRunState: states.NewState(),
|
|
|
|
PriorState: states.NewState(),
|
|
|
|
}
|
|
|
|
},
|
|
|
|
"Terraform has checked that the real remote objects still match",
|
|
|
|
},
|
|
|
|
"nothing at all in destroy mode": {
|
|
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
|
|
return &plans.Plan{
|
|
|
|
UIMode: plans.DestroyMode,
|
|
|
|
Changes: plans.NewChanges(),
|
|
|
|
PrevRunState: states.NewState(),
|
|
|
|
PriorState: states.NewState(),
|
|
|
|
}
|
|
|
|
},
|
|
|
|
"No objects need to be destroyed.",
|
|
|
|
},
|
2021-05-24 23:02:15 +02:00
|
|
|
"no drift to display with only deposed instances": {
|
|
|
|
// changes in deposed instances will cause a change in state, but
|
|
|
|
// have nothing to display to the user
|
|
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
|
|
return &plans.Plan{
|
|
|
|
UIMode: plans.NormalMode,
|
|
|
|
Changes: plans.NewChanges(),
|
|
|
|
PrevRunState: states.BuildState(func(state *states.SyncState) {
|
|
|
|
state.SetResourceInstanceDeposed(
|
|
|
|
addrs.Resource{
|
|
|
|
Mode: addrs.ManagedResourceMode,
|
|
|
|
Type: "test_resource",
|
|
|
|
Name: "somewhere",
|
|
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
|
|
states.NewDeposedKey(),
|
|
|
|
&states.ResourceInstanceObjectSrc{
|
|
|
|
Status: states.ObjectReady,
|
|
|
|
AttrsJSON: []byte(`{"foo": "ok", "bars":[]}`),
|
|
|
|
},
|
|
|
|
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
|
|
|
|
)
|
|
|
|
}),
|
|
|
|
PriorState: states.NewState(),
|
|
|
|
}
|
|
|
|
},
|
|
|
|
"no differences, so no changes are needed.",
|
|
|
|
},
|
2021-05-07 00:22:48 +02:00
|
|
|
"drift detected in normal mode": {
|
|
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
|
|
return &plans.Plan{
|
|
|
|
UIMode: plans.NormalMode,
|
|
|
|
Changes: plans.NewChanges(),
|
|
|
|
PrevRunState: states.BuildState(func(state *states.SyncState) {
|
|
|
|
state.SetResourceInstanceCurrent(
|
|
|
|
addrs.Resource{
|
|
|
|
Mode: addrs.ManagedResourceMode,
|
2021-05-24 21:38:58 +02:00
|
|
|
Type: "test_resource",
|
2021-05-07 00:22:48 +02:00
|
|
|
Name: "somewhere",
|
|
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
|
|
&states.ResourceInstanceObjectSrc{
|
|
|
|
Status: states.ObjectReady,
|
|
|
|
AttrsJSON: []byte(`{}`),
|
|
|
|
},
|
2021-05-24 21:38:58 +02:00
|
|
|
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
|
2021-05-07 00:22:48 +02:00
|
|
|
)
|
|
|
|
}),
|
|
|
|
PriorState: states.NewState(),
|
|
|
|
}
|
|
|
|
},
|
|
|
|
"to update the Terraform state to match, create and apply a refresh-only plan",
|
|
|
|
},
|
2021-05-24 21:38:58 +02:00
|
|
|
"drift detected with deposed": {
|
|
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
|
|
return &plans.Plan{
|
|
|
|
UIMode: plans.NormalMode,
|
|
|
|
Changes: plans.NewChanges(),
|
|
|
|
PrevRunState: states.BuildState(func(state *states.SyncState) {
|
|
|
|
state.SetResourceInstanceCurrent(
|
|
|
|
addrs.Resource{
|
|
|
|
Mode: addrs.ManagedResourceMode,
|
|
|
|
Type: "test_resource",
|
|
|
|
Name: "changes",
|
|
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
|
|
&states.ResourceInstanceObjectSrc{
|
|
|
|
Status: states.ObjectReady,
|
|
|
|
AttrsJSON: []byte(`{"foo":"b"}`),
|
|
|
|
},
|
|
|
|
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
|
|
|
|
)
|
|
|
|
state.SetResourceInstanceDeposed(
|
|
|
|
addrs.Resource{
|
|
|
|
Mode: addrs.ManagedResourceMode,
|
|
|
|
Type: "test_resource",
|
|
|
|
Name: "broken",
|
|
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
|
|
states.NewDeposedKey(),
|
|
|
|
&states.ResourceInstanceObjectSrc{
|
|
|
|
Status: states.ObjectReady,
|
|
|
|
AttrsJSON: []byte(`{"foo":"c"}`),
|
|
|
|
},
|
|
|
|
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
|
|
|
|
)
|
|
|
|
}),
|
|
|
|
PriorState: states.BuildState(func(state *states.SyncState) {
|
|
|
|
state.SetResourceInstanceCurrent(
|
|
|
|
addrs.Resource{
|
|
|
|
Mode: addrs.ManagedResourceMode,
|
|
|
|
Type: "test_resource",
|
|
|
|
Name: "changed",
|
|
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
|
|
&states.ResourceInstanceObjectSrc{
|
|
|
|
Status: states.ObjectReady,
|
|
|
|
AttrsJSON: []byte(`{"foo":"b"}`),
|
|
|
|
},
|
|
|
|
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
|
|
|
|
)
|
|
|
|
state.SetResourceInstanceDeposed(
|
|
|
|
addrs.Resource{
|
|
|
|
Mode: addrs.ManagedResourceMode,
|
|
|
|
Type: "test_resource",
|
|
|
|
Name: "broken",
|
|
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
|
|
states.NewDeposedKey(),
|
|
|
|
&states.ResourceInstanceObjectSrc{
|
|
|
|
Status: states.ObjectReady,
|
|
|
|
AttrsJSON: []byte(`{"foo":"d"}`),
|
|
|
|
},
|
|
|
|
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
|
|
|
|
)
|
|
|
|
}),
|
|
|
|
}
|
|
|
|
},
|
|
|
|
"to update the Terraform state to match, create and apply a refresh-only plan",
|
|
|
|
},
|
2021-05-07 00:22:48 +02:00
|
|
|
"drift detected in refresh-only mode": {
|
|
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
|
|
return &plans.Plan{
|
|
|
|
UIMode: plans.RefreshOnlyMode,
|
|
|
|
Changes: plans.NewChanges(),
|
|
|
|
PrevRunState: states.BuildState(func(state *states.SyncState) {
|
|
|
|
state.SetResourceInstanceCurrent(
|
|
|
|
addrs.Resource{
|
|
|
|
Mode: addrs.ManagedResourceMode,
|
2021-05-24 21:38:58 +02:00
|
|
|
Type: "test_resource",
|
2021-05-07 00:22:48 +02:00
|
|
|
Name: "somewhere",
|
|
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
|
|
&states.ResourceInstanceObjectSrc{
|
|
|
|
Status: states.ObjectReady,
|
|
|
|
AttrsJSON: []byte(`{}`),
|
|
|
|
},
|
2021-05-24 21:38:58 +02:00
|
|
|
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
|
2021-05-07 00:22:48 +02:00
|
|
|
)
|
|
|
|
}),
|
|
|
|
PriorState: states.NewState(),
|
|
|
|
}
|
|
|
|
},
|
|
|
|
"If you were expecting these changes then you can apply this plan",
|
|
|
|
},
|
|
|
|
"drift detected in destroy mode": {
|
|
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
|
|
return &plans.Plan{
|
|
|
|
UIMode: plans.DestroyMode,
|
|
|
|
Changes: plans.NewChanges(),
|
|
|
|
PrevRunState: states.BuildState(func(state *states.SyncState) {
|
|
|
|
state.SetResourceInstanceCurrent(
|
|
|
|
addrs.Resource{
|
|
|
|
Mode: addrs.ManagedResourceMode,
|
2021-05-24 21:38:58 +02:00
|
|
|
Type: "test_resource",
|
2021-05-07 00:22:48 +02:00
|
|
|
Name: "somewhere",
|
|
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
|
|
&states.ResourceInstanceObjectSrc{
|
|
|
|
Status: states.ObjectReady,
|
|
|
|
AttrsJSON: []byte(`{}`),
|
|
|
|
},
|
2021-05-24 21:38:58 +02:00
|
|
|
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
|
2021-05-07 00:22:48 +02:00
|
|
|
)
|
|
|
|
}),
|
|
|
|
PriorState: states.NewState(),
|
|
|
|
}
|
|
|
|
},
|
|
|
|
"No objects need to be destroyed.",
|
|
|
|
},
|
|
|
|
}
|
backend/local: Replace CLI with view instance
This commit extracts the remaining UI logic from the local backend,
and removes access to the direct CLI output. This is replaced with an
instance of a `views.Operation` interface, which codifies the current
requirements for the local backend to interact with the user.
The exception to this at present is interactivity: approving a plan
still depends on the `UIIn` field for the backend. This is out of scope
for this commit and can be revisited separately, at which time the
`UIOut` field can also be removed.
Changes in support of this:
- Some instances of direct error output have been replaced with
diagnostics, most notably in the emergency state backup handler. This
requires reformatting the error messages to allow the diagnostic
renderer to line-wrap them;
- The "in-automation" logic has moved out of the backend and into the
view implementation;
- The plan, apply, refresh, and import commands instantiate a view and
set it on the `backend.Operation` struct, as these are the only code
paths which call the `local.Operation()` method that requires it;
- The show command requires the plan rendering code which is now in the
views package, so there is a stub implementation of a `views.Show`
interface there.
Other refactoring work in support of migrating these commands to the
common views code structure will come in follow-up PRs, at which point
we will be able to remove the UI instances from the unit tests for those
commands.
2021-02-17 19:01:30 +01:00
|
|
|
|
2021-05-07 00:22:48 +02:00
|
|
|
schemas := testSchemas()
|
|
|
|
for name, test := range tests {
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
|
|
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
|
|
|
|
plan := test.plan(schemas)
|
|
|
|
v.Plan(plan, schemas)
|
|
|
|
got := done(t).Stdout()
|
|
|
|
if want := test.wantText; want != "" && !strings.Contains(got, want) {
|
|
|
|
t.Errorf("missing expected message\ngot:\n%s\n\nwant substring: %s", got, want)
|
|
|
|
}
|
|
|
|
})
|
backend/local: Replace CLI with view instance
This commit extracts the remaining UI logic from the local backend,
and removes access to the direct CLI output. This is replaced with an
instance of a `views.Operation` interface, which codifies the current
requirements for the local backend to interact with the user.
The exception to this at present is interactivity: approving a plan
still depends on the `UIIn` field for the backend. This is out of scope
for this commit and can be revisited separately, at which time the
`UIOut` field can also be removed.
Changes in support of this:
- Some instances of direct error output have been replaced with
diagnostics, most notably in the emergency state backup handler. This
requires reformatting the error messages to allow the diagnostic
renderer to line-wrap them;
- The "in-automation" logic has moved out of the backend and into the
view implementation;
- The plan, apply, refresh, and import commands instantiate a view and
set it on the `backend.Operation` struct, as these are the only code
paths which call the `local.Operation()` method that requires it;
- The show command requires the plan rendering code which is now in the
views package, so there is a stub implementation of a `views.Show`
interface there.
Other refactoring work in support of migrating these commands to the
common views code structure will come in follow-up PRs, at which point
we will be able to remove the UI instances from the unit tests for those
commands.
2021-02-17 19:01:30 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestOperation_plan(t *testing.T) {
|
|
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
|
|
v := NewOperation(arguments.ViewHuman, true, NewView(streams))
|
|
|
|
|
|
|
|
plan := testPlan(t)
|
|
|
|
schemas := testSchemas()
|
2021-05-06 00:24:58 +02:00
|
|
|
v.Plan(plan, schemas)
|
backend/local: Replace CLI with view instance
This commit extracts the remaining UI logic from the local backend,
and removes access to the direct CLI output. This is replaced with an
instance of a `views.Operation` interface, which codifies the current
requirements for the local backend to interact with the user.
The exception to this at present is interactivity: approving a plan
still depends on the `UIIn` field for the backend. This is out of scope
for this commit and can be revisited separately, at which time the
`UIOut` field can also be removed.
Changes in support of this:
- Some instances of direct error output have been replaced with
diagnostics, most notably in the emergency state backup handler. This
requires reformatting the error messages to allow the diagnostic
renderer to line-wrap them;
- The "in-automation" logic has moved out of the backend and into the
view implementation;
- The plan, apply, refresh, and import commands instantiate a view and
set it on the `backend.Operation` struct, as these are the only code
paths which call the `local.Operation()` method that requires it;
- The show command requires the plan rendering code which is now in the
views package, so there is a stub implementation of a `views.Show`
interface there.
Other refactoring work in support of migrating these commands to the
common views code structure will come in follow-up PRs, at which point
we will be able to remove the UI instances from the unit tests for those
commands.
2021-02-17 19:01:30 +01:00
|
|
|
|
|
|
|
want := `
|
|
|
|
Terraform used the selected providers to generate the following execution
|
|
|
|
plan. Resource actions are indicated with the following symbols:
|
|
|
|
+ create
|
|
|
|
|
|
|
|
Terraform will perform the following actions:
|
|
|
|
|
|
|
|
# test_resource.foo will be created
|
|
|
|
+ resource "test_resource" "foo" {
|
|
|
|
+ foo = "bar"
|
|
|
|
+ id = (known after apply)
|
|
|
|
}
|
|
|
|
|
|
|
|
Plan: 1 to add, 0 to change, 0 to destroy.
|
|
|
|
`
|
|
|
|
|
|
|
|
if got := done(t).Stdout(); got != want {
|
|
|
|
t.Errorf("unexpected output\ngot:\n%s\nwant:\n%s", got, want)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestOperation_planNextStep(t *testing.T) {
|
|
|
|
testCases := map[string]struct {
|
|
|
|
path string
|
|
|
|
want string
|
|
|
|
}{
|
|
|
|
"no state path": {
|
|
|
|
path: "",
|
|
|
|
want: "You didn't use the -out option",
|
|
|
|
},
|
|
|
|
"state path": {
|
|
|
|
path: "good plan.tfplan",
|
|
|
|
want: `terraform apply "good plan.tfplan"`,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
for name, tc := range testCases {
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
|
|
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
|
|
|
|
|
|
|
|
v.PlanNextStep(tc.path)
|
|
|
|
|
|
|
|
if got := done(t).Stdout(); !strings.Contains(got, tc.want) {
|
|
|
|
t.Errorf("wrong result\ngot: %q\nwant: %q", got, tc.want)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// The in-automation state is on the view itself, so testing it separately is
|
|
|
|
// clearer.
|
|
|
|
func TestOperation_planNextStepInAutomation(t *testing.T) {
|
|
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
|
|
v := NewOperation(arguments.ViewHuman, true, NewView(streams))
|
|
|
|
|
|
|
|
v.PlanNextStep("")
|
|
|
|
|
|
|
|
if got := done(t).Stdout(); got != "" {
|
|
|
|
t.Errorf("unexpected output\ngot: %q", got)
|
|
|
|
}
|
|
|
|
}
|
2021-02-23 16:16:09 +01:00
|
|
|
|
|
|
|
// Test all the trivial OperationJSON methods together. Y'know, for brevity.
|
|
|
|
// This test is not a realistic stream of messages.
|
|
|
|
func TestOperationJSON_logs(t *testing.T) {
|
|
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
|
|
v := &OperationJSON{view: NewJSONView(NewView(streams))}
|
|
|
|
|
|
|
|
v.Cancelled(plans.NormalMode)
|
|
|
|
v.Cancelled(plans.DestroyMode)
|
|
|
|
v.Stopping()
|
|
|
|
v.Interrupted()
|
|
|
|
v.FatalInterrupt()
|
|
|
|
|
|
|
|
want := []map[string]interface{}{
|
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": "Apply cancelled",
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "log",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": "Destroy cancelled",
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "log",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": "Stopping operation...",
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "log",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": interrupted,
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "log",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": fatalInterrupt,
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "log",
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
|
|
}
|
|
|
|
|
|
|
|
// This is a fairly circular test, but it's such a rarely executed code path
|
|
|
|
// that I think it's probably still worth having. We're not testing against
|
|
|
|
// a fixed state JSON output because this test ought not fail just because
|
|
|
|
// we upgrade state format in the future.
|
|
|
|
func TestOperationJSON_emergencyDumpState(t *testing.T) {
|
|
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
|
|
v := &OperationJSON{view: NewJSONView(NewView(streams))}
|
|
|
|
|
|
|
|
stateFile := statefile.New(nil, "foo", 1)
|
|
|
|
stateBuf := new(bytes.Buffer)
|
|
|
|
err := statefile.Write(stateFile, stateBuf)
|
|
|
|
if err != nil {
|
|
|
|
t.Fatal(err)
|
|
|
|
}
|
|
|
|
var stateJSON map[string]interface{}
|
|
|
|
err = json.Unmarshal(stateBuf.Bytes(), &stateJSON)
|
|
|
|
if err != nil {
|
|
|
|
t.Fatal(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
err = v.EmergencyDumpState(stateFile)
|
|
|
|
if err != nil {
|
|
|
|
t.Fatalf("unexpected error dumping state: %s", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
want := []map[string]interface{}{
|
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": "Emergency state dump",
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "log",
|
|
|
|
"state": stateJSON,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestOperationJSON_planNoChanges(t *testing.T) {
|
|
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
|
|
v := &OperationJSON{view: NewJSONView(NewView(streams))}
|
|
|
|
|
2021-05-07 00:22:48 +02:00
|
|
|
plan := &plans.Plan{
|
|
|
|
Changes: plans.NewChanges(),
|
|
|
|
}
|
|
|
|
v.Plan(plan, nil)
|
2021-02-23 16:16:09 +01:00
|
|
|
|
|
|
|
want := []map[string]interface{}{
|
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": "Plan: 0 to add, 0 to change, 0 to destroy.",
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "change_summary",
|
|
|
|
"changes": map[string]interface{}{
|
|
|
|
"operation": "plan",
|
|
|
|
"add": float64(0),
|
|
|
|
"change": float64(0),
|
|
|
|
"remove": float64(0),
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestOperationJSON_plan(t *testing.T) {
|
|
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
|
|
v := &OperationJSON{view: NewJSONView(NewView(streams))}
|
|
|
|
|
|
|
|
root := addrs.RootModuleInstance
|
|
|
|
vpc, diags := addrs.ParseModuleInstanceStr("module.vpc")
|
|
|
|
if len(diags) > 0 {
|
|
|
|
t.Fatal(diags.Err())
|
|
|
|
}
|
|
|
|
boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "boop"}
|
|
|
|
beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "beep"}
|
|
|
|
derp := addrs.Resource{Mode: addrs.DataResourceMode, Type: "test_source", Name: "derp"}
|
|
|
|
|
|
|
|
plan := &plans.Plan{
|
|
|
|
Changes: &plans.Changes{
|
|
|
|
Resources: []*plans.ResourceInstanceChangeSrc{
|
|
|
|
{
|
|
|
|
Addr: boop.Instance(addrs.IntKey(0)).Absolute(root),
|
|
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.CreateThenDelete},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
Addr: boop.Instance(addrs.IntKey(1)).Absolute(root),
|
|
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.Create},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
Addr: boop.Instance(addrs.IntKey(0)).Absolute(vpc),
|
|
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.Delete},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
Addr: beep.Instance(addrs.NoKey).Absolute(root),
|
|
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.DeleteThenCreate},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
Addr: beep.Instance(addrs.NoKey).Absolute(vpc),
|
|
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.Update},
|
|
|
|
},
|
|
|
|
// Data source deletion should not show up in the logs
|
|
|
|
{
|
|
|
|
Addr: derp.Instance(addrs.NoKey).Absolute(root),
|
|
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.Delete},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}
|
2021-05-06 00:24:58 +02:00
|
|
|
v.Plan(plan, nil)
|
2021-02-23 16:16:09 +01:00
|
|
|
|
|
|
|
want := []map[string]interface{}{
|
|
|
|
// Create-then-delete should result in replace
|
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": "test_instance.boop[0]: Plan to replace",
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "planned_change",
|
|
|
|
"change": map[string]interface{}{
|
|
|
|
"action": "replace",
|
|
|
|
"resource": map[string]interface{}{
|
|
|
|
"addr": `test_instance.boop[0]`,
|
|
|
|
"implied_provider": "test",
|
|
|
|
"module": "",
|
|
|
|
"resource": `test_instance.boop[0]`,
|
|
|
|
"resource_key": float64(0),
|
|
|
|
"resource_name": "boop",
|
|
|
|
"resource_type": "test_instance",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
// Simple create
|
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": "test_instance.boop[1]: Plan to create",
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "planned_change",
|
|
|
|
"change": map[string]interface{}{
|
|
|
|
"action": "create",
|
|
|
|
"resource": map[string]interface{}{
|
|
|
|
"addr": `test_instance.boop[1]`,
|
|
|
|
"implied_provider": "test",
|
|
|
|
"module": "",
|
|
|
|
"resource": `test_instance.boop[1]`,
|
|
|
|
"resource_key": float64(1),
|
|
|
|
"resource_name": "boop",
|
|
|
|
"resource_type": "test_instance",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
// Simple delete
|
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": "module.vpc.test_instance.boop[0]: Plan to delete",
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "planned_change",
|
|
|
|
"change": map[string]interface{}{
|
|
|
|
"action": "delete",
|
|
|
|
"resource": map[string]interface{}{
|
|
|
|
"addr": `module.vpc.test_instance.boop[0]`,
|
|
|
|
"implied_provider": "test",
|
|
|
|
"module": "module.vpc",
|
|
|
|
"resource": `test_instance.boop[0]`,
|
|
|
|
"resource_key": float64(0),
|
|
|
|
"resource_name": "boop",
|
|
|
|
"resource_type": "test_instance",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
// Delete-then-create is also a replace
|
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": "test_instance.beep: Plan to replace",
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "planned_change",
|
|
|
|
"change": map[string]interface{}{
|
|
|
|
"action": "replace",
|
|
|
|
"resource": map[string]interface{}{
|
|
|
|
"addr": `test_instance.beep`,
|
|
|
|
"implied_provider": "test",
|
|
|
|
"module": "",
|
|
|
|
"resource": `test_instance.beep`,
|
|
|
|
"resource_key": nil,
|
|
|
|
"resource_name": "beep",
|
|
|
|
"resource_type": "test_instance",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
// Simple update
|
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": "module.vpc.test_instance.beep: Plan to update",
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "planned_change",
|
|
|
|
"change": map[string]interface{}{
|
|
|
|
"action": "update",
|
|
|
|
"resource": map[string]interface{}{
|
|
|
|
"addr": `module.vpc.test_instance.beep`,
|
|
|
|
"implied_provider": "test",
|
|
|
|
"module": "module.vpc",
|
|
|
|
"resource": `test_instance.beep`,
|
|
|
|
"resource_key": nil,
|
|
|
|
"resource_name": "beep",
|
|
|
|
"resource_type": "test_instance",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
// These counts are 3 add/1 change/3 destroy because the replace
|
|
|
|
// changes result in both add and destroy counts.
|
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": "Plan: 3 to add, 1 to change, 3 to destroy.",
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "change_summary",
|
|
|
|
"changes": map[string]interface{}{
|
|
|
|
"operation": "plan",
|
|
|
|
"add": float64(3),
|
|
|
|
"change": float64(1),
|
|
|
|
"remove": float64(3),
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestOperationJSON_plannedChange(t *testing.T) {
|
|
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
|
|
v := &OperationJSON{view: NewJSONView(NewView(streams))}
|
|
|
|
|
|
|
|
root := addrs.RootModuleInstance
|
|
|
|
boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "boop"}
|
|
|
|
derp := addrs.Resource{Mode: addrs.DataResourceMode, Type: "test_source", Name: "derp"}
|
|
|
|
|
2021-05-03 12:49:42 +02:00
|
|
|
// Replace requested by user
|
|
|
|
v.PlannedChange(&plans.ResourceInstanceChangeSrc{
|
|
|
|
Addr: boop.Instance(addrs.IntKey(0)).Absolute(root),
|
|
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.DeleteThenCreate},
|
|
|
|
ActionReason: plans.ResourceInstanceReplaceByRequest,
|
|
|
|
})
|
|
|
|
|
2021-02-23 16:16:09 +01:00
|
|
|
// Simple create
|
|
|
|
v.PlannedChange(&plans.ResourceInstanceChangeSrc{
|
2021-05-03 12:49:42 +02:00
|
|
|
Addr: boop.Instance(addrs.IntKey(1)).Absolute(root),
|
2021-02-23 16:16:09 +01:00
|
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.Create},
|
|
|
|
})
|
|
|
|
|
|
|
|
// Data source deletion
|
|
|
|
v.PlannedChange(&plans.ResourceInstanceChangeSrc{
|
|
|
|
Addr: derp.Instance(addrs.NoKey).Absolute(root),
|
|
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.Delete},
|
|
|
|
})
|
|
|
|
|
2021-05-03 12:49:42 +02:00
|
|
|
// Expect only two messages, as the data source deletion should be a no-op
|
2021-02-23 16:16:09 +01:00
|
|
|
want := []map[string]interface{}{
|
|
|
|
{
|
|
|
|
"@level": "info",
|
2021-05-03 12:49:42 +02:00
|
|
|
"@message": "test_instance.boop[0]: Plan to replace",
|
2021-02-23 16:16:09 +01:00
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "planned_change",
|
|
|
|
"change": map[string]interface{}{
|
2021-05-03 12:49:42 +02:00
|
|
|
"action": "replace",
|
|
|
|
"reason": "requested",
|
2021-02-23 16:16:09 +01:00
|
|
|
"resource": map[string]interface{}{
|
|
|
|
"addr": `test_instance.boop[0]`,
|
|
|
|
"implied_provider": "test",
|
|
|
|
"module": "",
|
|
|
|
"resource": `test_instance.boop[0]`,
|
|
|
|
"resource_key": float64(0),
|
|
|
|
"resource_name": "boop",
|
|
|
|
"resource_type": "test_instance",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
2021-05-03 12:49:42 +02:00
|
|
|
{
|
|
|
|
"@level": "info",
|
|
|
|
"@message": "test_instance.boop[1]: Plan to create",
|
|
|
|
"@module": "terraform.ui",
|
|
|
|
"type": "planned_change",
|
|
|
|
"change": map[string]interface{}{
|
|
|
|
"action": "create",
|
|
|
|
"resource": map[string]interface{}{
|
|
|
|
"addr": `test_instance.boop[1]`,
|
|
|
|
"implied_provider": "test",
|
|
|
|
"module": "",
|
|
|
|
"resource": `test_instance.boop[1]`,
|
|
|
|
"resource_key": float64(1),
|
|
|
|
"resource_name": "boop",
|
|
|
|
"resource_type": "test_instance",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
2021-02-23 16:16:09 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
|
|
}
|