| // Copyright IBM Corp. 2014, 2026 | |
| // 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 | |
| } | |
| generateConfigOut := len(op.GenerateConfigOut) > 0 | |
| queryRunOptions := tfe.QueryRunCreateOptions{ | |
| ConfigurationVersion: cv, | |
| Workspace: w, | |
| Source: tfe.QueryRunSourceAPI, | |
| GenerateConfigOut: &generateConfigOut, | |
| } | |
| 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] | |
| ` |