terraform/internal/cloud/backend_query.go
Michael Yocca 810f497ecd
Some checks are pending
build / Determine intended Terraform version (push) Waiting to run
build / Determine Go toolchain version (push) Waiting to run
build / Generate release metadata (push) Blocked by required conditions
build / Build for freebsd_386 (push) Blocked by required conditions
build / Build for linux_386 (push) Blocked by required conditions
build / Build for openbsd_386 (push) Blocked by required conditions
build / Build for windows_386 (push) Blocked by required conditions
build / Build for darwin_amd64 (push) Blocked by required conditions
build / Build for freebsd_amd64 (push) Blocked by required conditions
build / Build for linux_amd64 (push) Blocked by required conditions
build / Build for openbsd_amd64 (push) Blocked by required conditions
build / Build for solaris_amd64 (push) Blocked by required conditions
build / Build for windows_amd64 (push) Blocked by required conditions
build / Build for freebsd_arm (push) Blocked by required conditions
build / Build for linux_arm (push) Blocked by required conditions
build / Build for darwin_arm64 (push) Blocked by required conditions
build / Build for linux_arm64 (push) Blocked by required conditions
build / Build for windows_arm64 (push) Blocked by required conditions
build / Build Docker image for linux_386 (push) Blocked by required conditions
build / Build Docker image for linux_amd64 (push) Blocked by required conditions
build / Build Docker image for linux_arm (push) Blocked by required conditions
build / Build Docker image for linux_arm64 (push) Blocked by required conditions
build / Build e2etest for linux_386 (push) Blocked by required conditions
build / Build e2etest for windows_386 (push) Blocked by required conditions
build / Build e2etest for darwin_amd64 (push) Blocked by required conditions
build / Build e2etest for linux_amd64 (push) Blocked by required conditions
build / Build e2etest for windows_amd64 (push) Blocked by required conditions
build / Build e2etest for linux_arm (push) Blocked by required conditions
build / Build e2etest for darwin_arm64 (push) Blocked by required conditions
build / Build e2etest for linux_arm64 (push) Blocked by required conditions
build / Run e2e test for linux_386 (push) Blocked by required conditions
build / Run e2e test for windows_386 (push) Blocked by required conditions
build / Run e2e test for darwin_amd64 (push) Blocked by required conditions
build / Run e2e test for linux_amd64 (push) Blocked by required conditions
build / Run e2e test for windows_amd64 (push) Blocked by required conditions
build / Run e2e test for linux_arm (push) Blocked by required conditions
build / Run e2e test for linux_arm64 (push) Blocked by required conditions
build / Run terraform-exec test for linux amd64 (push) Blocked by required conditions
Quick Checks / Unit Tests (push) Waiting to run
Quick Checks / Race Tests (push) Waiting to run
Quick Checks / End-to-end Tests (push) Waiting to run
Quick Checks / Code Consistency Checks (push) Waiting to run
fix(cloud): cloud backend rendering query warning diags (#38040)
2026-01-13 08:48:15 -08:00

310 lines
8.8 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cloud
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"strings"
"time"
tfe "github.com/hashicorp/go-tfe"
"github.com/hashicorp/terraform/internal/backend/backendrun"
"github.com/hashicorp/terraform/internal/command/jsonformat"
viewsjson "github.com/hashicorp/terraform/internal/command/views/json"
"github.com/hashicorp/terraform/internal/genconfig"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
)
func (b *Cloud) opQuery(stopCtx, cancelCtx context.Context, op *backendrun.Operation, w *tfe.Workspace) (OperationResult, error) {
log.Printf("[INFO] cloud: starting Query operation")
var diags tfdiags.Diagnostics
// TODO? maybe check workspace permissions if the user has the permission to run queries
if len(op.GenerateConfigOut) > 0 {
diags = diags.Append(genconfig.ValidateTargetFile(op.GenerateConfigOut))
}
if diags.HasErrors() {
return &QueryRunResult{}, diags.Err()
}
return b.query(stopCtx, cancelCtx, op, w)
}
func (b *Cloud) query(stopCtx, cancelCtx context.Context, op *backendrun.Operation, w *tfe.Workspace) (OperationResult, error) {
if b.CLI != nil {
header := fmt.Sprintf(queryDefaultHeader, b.appName)
b.CLI.Output(b.Colorize().Color(strings.TrimSpace(header) + "\n"))
}
configOptions := tfe.ConfigurationVersionCreateOptions{
AutoQueueRuns: tfe.Bool(false),
}
cv, err := b.uploadConfigurationVersion(stopCtx, cancelCtx, op, w, configOptions)
if err != nil {
return &QueryRunResult{}, err
}
queryRunOptions := tfe.QueryRunCreateOptions{
ConfigurationVersion: cv,
Workspace: w,
Source: tfe.QueryRunSourceAPI,
}
runVariables, err := b.parseRunVariables(op)
if err != nil {
return nil, err
}
queryRunOptions.Variables = runVariables
r, err := b.client.QueryRuns.Create(stopCtx, queryRunOptions)
if err != nil {
return &QueryRunResult{}, b.generalError("Failed to create query run", err)
}
if b.CLI != nil {
// TODO replace with URL from create response, once available
b.CLI.Output(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf(
queryRunHeader, b.Hostname, b.Organization, op.Workspace, r.ID)) + "\n"))
}
r, err = b.waitForQueryRun(stopCtx, cancelCtx, r)
if err != nil {
return &QueryRunResult{run: r, backend: b}, err
}
err = b.renderQueryRunLogs(stopCtx, op, r)
if err != nil {
return &QueryRunResult{run: r, backend: b}, err
}
return &QueryRunResult{run: r, backend: b}, nil
}
func (b *Cloud) renderQueryRunLogs(ctx context.Context, op *backendrun.Operation, run *tfe.QueryRun) error {
logs, err := b.client.QueryRuns.Logs(ctx, run.ID)
if err != nil {
return err
}
configs := map[string]string{}
wantConfig := len(op.GenerateConfigOut) > 0
if b.CLI != nil {
reader := bufio.NewReaderSize(logs, 64*1024)
results := map[string][]*viewsjson.QueryResult{}
for next := true; next; {
var l, line []byte
var err error
for isPrefix := true; isPrefix; {
l, isPrefix, err = reader.ReadLine()
if err != nil {
if err != io.EOF {
return b.generalError("Failed to read logs", err)
}
next = false
}
line = append(line, l...)
}
if next || len(line) > 0 {
log := &jsonformat.JSONLog{}
if err := json.Unmarshal(line, log); err != nil {
// If we can not parse the line as JSON, we will simply
// print the line. This maintains backwards compatibility for
// users who do not wish to enable structured output in their
// workspace.
b.CLI.Output(string(line))
continue
}
if b.renderer != nil {
// Instead of using renderer.RenderLog for individual log messages,
// we collect all logs of a list block and output them at once.
// This allows us to ensure all messages of a list block are grouped
// and indented as in the PostListQuery hook.
switch log.Type {
case jsonformat.LogListStart:
results[log.ListQueryStart.Address] = make([]*viewsjson.QueryResult, 0)
case jsonformat.LogListResourceFound:
results[log.ListQueryResult.Address] = append(results[log.ListQueryResult.Address], log.ListQueryResult)
if wantConfig {
configs[log.ListQueryResult.Address] +=
fmt.Sprintf("%s\n%s\n\n", log.ListQueryResult.Config, log.ListQueryResult.ImportConfig)
}
case jsonformat.LogListComplete:
addr := log.ListQueryComplete.Address
identities := make([]string, 0, len(results[addr]))
displayNames := make([]string, 0, len(results[addr]))
maxIdentityLen := 0
for _, result := range results[addr] {
identity := formatIdentity(result.Identity)
if len(identity) > maxIdentityLen {
maxIdentityLen = len(identity)
}
identities = append(identities, identity)
displayNames = append(displayNames, result.DisplayName)
}
result := strings.Builder{}
for i, identity := range identities {
result.WriteString(fmt.Sprintf("%s %-*s %s\n", addr, maxIdentityLen, identity, displayNames[i]))
}
if result.Len() > 0 {
b.renderer.Streams.Println(result.String())
}
default:
err := b.renderer.RenderLog(log)
if err != nil {
return err
}
}
}
}
}
}
if wantConfig && len(configs) > 0 {
diags := genconfig.ValidateTargetFile(op.GenerateConfigOut)
if diags.HasErrors() {
return diags.Err()
}
var writer io.Writer
for addr, config := range configs {
change := genconfig.Change{
Addr: addr,
GeneratedConfig: config,
}
writer, _, diags = change.MaybeWriteConfig(writer, op.GenerateConfigOut)
if diags.HasErrors() {
return diags.Err()
}
}
}
return nil
}
func (b *Cloud) waitForQueryRun(stopCtx, cancelCtx context.Context, r *tfe.QueryRun) (*tfe.QueryRun, error) {
started := time.Now()
updated := started
for i := 0; ; i++ {
select {
case <-stopCtx.Done():
return r, stopCtx.Err()
case <-cancelCtx.Done():
return r, cancelCtx.Err()
case <-time.After(backoff(backoffMin, backoffMax, i)):
// Timer up, show status
}
// Retrieve the run to get its current status.
r, err := b.client.QueryRuns.Read(stopCtx, r.ID)
if err != nil {
return r, b.generalError("Failed to retrieve run", err)
}
// Return if the run is no longer pending.
if r.Status != tfe.QueryRunPending {
if i == 0 && b.CLI != nil {
b.CLI.Output(b.Colorize().Color("Waiting for the query run to start...\n"))
}
if i > 0 && b.CLI != nil {
// Insert a blank line to separate the ouputs.
b.CLI.Output("")
}
return r, nil
}
// Check if 30 seconds have passed since the last update.
current := time.Now()
if b.CLI != nil && (i == 0 || current.Sub(updated).Seconds() > 30) {
updated = current
elapsed := ""
// Calculate and set the elapsed time.
if i > 0 {
elapsed = fmt.Sprintf(
" (%s elapsed)", current.Sub(started).Truncate(30*time.Second))
}
b.CLI.Output(b.Colorize().Color(fmt.Sprintf("Waiting for the query run to start...%s", elapsed)))
}
}
}
func (b *Cloud) cancelQueryRun(cancelCtx context.Context, op *backendrun.Operation, r *tfe.QueryRun) error {
v, err := op.UIIn.Input(cancelCtx, &terraform.InputOpts{
Id: "cancel",
Query: "\nDo you want to cancel the remote operation?",
Description: "Only 'yes' will be accepted to cancel.",
})
if err != nil {
return b.generalError("Failed asking to cancel", err)
}
if v != "yes" {
if b.CLI != nil {
b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationNotCanceled)))
}
return nil
}
// Try to cancel the remote operation.
err = b.client.QueryRuns.Cancel(cancelCtx, r.ID)
if err != nil {
return b.generalError("Failed to cancel query run", err)
}
if b.CLI != nil {
b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationCanceled)))
}
return nil
}
// formatIdentity formats the identity map into a string representation.
// It flattens the map into a string of key=value pairs, separated by commas.
func formatIdentity(identity map[string]json.RawMessage) string {
ctyObj := make(map[string]cty.Value, len(identity))
for key, value := range identity {
ty, err := ctyjson.ImpliedType(value)
if err != nil {
continue
}
v, err := ctyjson.Unmarshal(value, ty)
if err != nil {
continue
}
ctyObj[key] = v
}
return tfdiags.ObjectToString(cty.ObjectVal(ctyObj))
}
const queryDefaultHeader = `
[reset][yellow]Running query in %s. Output will stream here. Pressing Ctrl-C
will stop streaming the logs, but will not stop the query running remotely.[reset]
Preparing the remote query run...
`
const queryRunHeader = `
[reset][yellow]To view this query run in a browser, visit:[reset]
[reset][yellow]https://%s/app/%s/%s/search/%s[reset]
`