Skip to content

Stevenmasley/tfparse to preview #18650

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions archive/fs/zip.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package archivefs

import (
"archive/zip"
"io"
"io/fs"

"github.com/spf13/afero"
"github.com/spf13/afero/zipfs"
)

// FromZipReader creates a read-only in-memory FS
func FromZipReader(r io.ReaderAt, size int64) (fs.FS, error) {
zr, err := zip.NewReader(r, size)
if err != nil {
return nil, err
}
return afero.NewIOFS(zipfs.New(zr)), nil
}
46 changes: 33 additions & 13 deletions coderd/templateversions.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
package coderd

import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"strings"

"github.com/go-chi/chi/v5"
"github.com/google/uuid"
Expand All @@ -18,6 +21,8 @@ import (
"golang.org/x/xerrors"

"cdr.dev/slog"
archivefs "github.com/coder/coder/v2/archive/fs"
"github.com/coder/preview"

"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
Expand Down Expand Up @@ -1589,36 +1594,51 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
}
}()

if err := tfparse.WriteArchive(file.Data, file.Mimetype, tempDir); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error checking workspace tags",
Detail: "extract archive to tempdir: " + err.Error(),
})
return
var files fs.FS
switch file.Mimetype {
case "application/x-tar":
files = archivefs.FromTarReader(bytes.NewBuffer(file.Data))
case "application/zip":
files, err = archivefs.FromZipReader(bytes.NewReader(file.Data), int64(len(file.Data)))
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error checking workspace tags",
Detail: "extract archive to tempdir: " + err.Error(),
})
return
}
}

parser, diags := tfparse.New(tempDir, tfparse.WithLogger(api.Logger.Named("tfparse")))
output, diags := preview.Preview(ctx, preview.Input{}, files)
if diags.HasErrors() {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error checking workspace tags",
Message: "Internal error parsing workspace tags",
Detail: "parse module: " + diags.Error(),
})
return
}
parsedTags := output.WorkspaceTags

failedTags := parsedTags.UnusableTags()
if len(failedTags) > 0 {
validations := make([]codersdk.ValidationError, 0, len(failedTags))
names := make([]string, 0, len(failedTags))
for _, tag := range failedTags {
validations = append(validations, tfparse.TagValidationResponse(tag))
}

parsedTags, err := parser.WorkspaceTagDefaults(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error checking workspace tags",
Detail: "evaluate default values of workspace tags: " + err.Error(),
Message: "Internal error checking workspace tags",
Detail: fmt.Sprintf("evaluate default values of workspace tags [%s]", strings.Join(names, ", ")),
Validations: validations,
})
return
}

// Ensure the "owner" tag is properly applied in addition to request tags and coder_workspace_tags.
// User-specified tags in the request will take precedence over tags parsed from `coder_workspace_tags`
// data sources defined in the template file.
tags := provisionersdk.MutateTags(apiKey.UserID, parsedTags, req.ProvisionerTags)
tags := provisionersdk.MutateTags(apiKey.UserID, parsedTags.Tags(), req.ProvisionerTags)

var templateVersion database.TemplateVersion
var provisionerJob database.ProvisionerJob
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ require (
require (
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
github.com/coder/aisdk-go v0.0.9
github.com/coder/preview v1.0.1
github.com/coder/preview v1.0.3-0.20250627161416-e1ccd88ba6c0
github.com/fsnotify/fsnotify v1.9.0
github.com/mark3labs/mcp-go v0.32.0
)
Expand Down Expand Up @@ -538,3 +538,5 @@ require (
google.golang.org/genai v1.12.0 // indirect
k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect
)

replace github.com/coder/preview => /home/steven/go/src/github.com/coder/preview/
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -916,8 +916,8 @@ github.com/coder/pq v1.10.5-0.20240813183442-0c420cb5a048 h1:3jzYUlGH7ZELIH4XggX
github.com/coder/pq v1.10.5-0.20240813183442-0c420cb5a048/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs=
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc=
github.com/coder/preview v1.0.1 h1:f6q+RjNelwnkyXfGbmVlb4dcUOQ0z4mPsb2kuQpFHuU=
github.com/coder/preview v1.0.1/go.mod h1:efDWGlO/PZPrvdt5QiDhMtTUTkPxejXo9c0wmYYLLjM=
github.com/coder/preview v1.0.3-0.20250627161416-e1ccd88ba6c0 h1:6fbDlBk0MjSqkantXyLO1tN4fl40lnh90Q8ZTk9vXVA=
github.com/coder/preview v1.0.3-0.20250627161416-e1ccd88ba6c0/go.mod h1:efDWGlO/PZPrvdt5QiDhMtTUTkPxejXo9c0wmYYLLjM=
github.com/coder/quartz v0.2.1 h1:QgQ2Vc1+mvzewg2uD/nj8MJ9p9gE+QhGJm+Z+NGnrSE=
github.com/coder/quartz v0.2.1/go.mod h1:vsiCc+AHViMKH2CQpGIpFgdHIEQsxwm8yCscqKmzbRA=
github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc=
Expand Down
41 changes: 22 additions & 19 deletions provisioner/terraform/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@ package terraform

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/hashicorp/terraform-config-inspect/tfconfig"
"github.com/hashicorp/hcl/v2"
"github.com/mitchellh/go-wordwrap"

"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/provisioner/terraform/tfparse"
"github.com/coder/coder/v2/provisionersdk"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/preview"
)

// Parse extracts Terraform variables from source-code.
Expand All @@ -21,50 +22,52 @@ func (s *server) Parse(sess *provisionersdk.Session, _ *proto.ParseRequest, _ <-
defer span.End()

// Load the module and print any parse errors.
parser, diags := tfparse.New(sess.WorkDirectory, tfparse.WithLogger(s.logger.Named("tfparse")))
output, diags := preview.Preview(ctx, preview.Input{}, os.DirFS(sess.WorkDirectory))
if diags.HasErrors() {
return provisionersdk.ParseErrorf("load module: %s", formatDiagnostics(sess.WorkDirectory, diags))
}

workspaceTags, _, err := parser.WorkspaceTags(ctx)
if err != nil {
return provisionersdk.ParseErrorf("can't load workspace tags: %v", err)
tags := output.WorkspaceTags
failedTags := tags.UnusableTags()
if len(failedTags) > 0 {
return provisionersdk.ParseErrorf("can't load workspace tags: %v", failedTags.SafeNames())
}

templateVariables, err := parser.TemplateVariables()
if err != nil {
return provisionersdk.ParseErrorf("can't load template variables: %v", err)
}
// TODO: THIS
//templateVariables, err := parser.TemplateVariables()
//if err != nil {
// return provisionersdk.ParseErrorf("can't load template variables: %v", err)
//}

return &proto.ParseComplete{
TemplateVariables: templateVariables,
WorkspaceTags: workspaceTags,
TemplateVariables: nil, // TODO: Handle template variables.
WorkspaceTags: tags.Tags(),
}
}

// FormatDiagnostics returns a nicely formatted string containing all of the
// error details within the tfconfig.Diagnostics. We need to use this because
// the default format doesn't provide much useful information.
func formatDiagnostics(baseDir string, diags tfconfig.Diagnostics) string {
func formatDiagnostics(baseDir string, diags hcl.Diagnostics) string {
var msgs strings.Builder
for _, d := range diags {
// Convert severity.
severity := "UNKNOWN SEVERITY"
switch {
case d.Severity == tfconfig.DiagError:
case d.Severity == hcl.DiagError:
severity = "ERROR"
case d.Severity == tfconfig.DiagWarning:
case d.Severity == hcl.DiagWarning:
severity = "WARN"
}

// Determine filepath and line
location := "unknown location"
if d.Pos != nil {
filename, err := filepath.Rel(baseDir, d.Pos.Filename)
if d.Subject != nil {
filename, err := filepath.Rel(baseDir, d.Subject.Filename)
if err != nil {
filename = d.Pos.Filename
filename = d.Subject.Filename
}
location = fmt.Sprintf("%s:%d", filename, d.Pos.Line)
location = fmt.Sprintf("%s:%d", filename, d.Subject.Start.Line)
}

_, _ = msgs.WriteString(fmt.Sprintf("\n%s: %s (%s)\n", severity, d.Summary, location))
Expand Down
99 changes: 99 additions & 0 deletions provisioner/terraform/tfparse/previewtags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package tfparse

import (
"context"
"fmt"
"io/fs"

"github.com/hashicorp/hcl/v2"

"github.com/coder/coder/v2/codersdk"
"github.com/coder/preview"
previewtypes "github.com/coder/preview/types"
)

type Parsed struct {
output *preview.Output
}

//
//// TODO: Maybe swap workdir with an fs.FS interface?
//func New(files fs.FS, opt ...Option) (*Parser, hcl.Diagnostics) {
// return &Parser{
// logger: slog.Logger{},
// workdir: files,
// }, nil
//}

func New(ctx context.Context, workdir fs.FS, input preview.Input) (*preview.Output, hcl.Diagnostics) {
output, diags := preview.Preview(ctx, input, workdir)

if diags.HasErrors() {
return nil, diags
}
return output, nil
}

func TagValidationResponse(tag previewtypes.Tag) codersdk.ValidationError {
name := tag.KeyString()
if name == previewtypes.UnknownStringValue {
name = "unknown"
}

const (
key = "key"
value = "value"
)

diagErr := "Invalid tag %s: %s"
if tag.Key.ValueDiags.HasErrors() {
return codersdk.ValidationError{
Field: name,
Detail: fmt.Sprintf(diagErr, key, tag.Key.ValueDiags.Error()),
}
}

if tag.Value.ValueDiags.HasErrors() {
return codersdk.ValidationError{
Field: name,
Detail: fmt.Sprintf(diagErr, value, tag.Value.ValueDiags.Error()),
}
}

// TODO: It would be really nice to pull out the variable references to help identify the source of
// the unknown or invalid tag.
unknownErr := "Tag %s is not known, it likely refers to a variable that is not set or has no default."
if !tag.Key.IsKnown() {
return codersdk.ValidationError{
Field: name,
Detail: fmt.Sprintf(unknownErr, key),
}
}

if !tag.Value.IsKnown() {
return codersdk.ValidationError{
Field: name,
Detail: fmt.Sprintf(unknownErr, value),
}
}

invalidErr := "Tag %s is not valid, it must be a non-null string value."
if !tag.Key.Valid() {
return codersdk.ValidationError{
Field: name,
Detail: fmt.Sprintf(invalidErr, key),
}
}

if !tag.Value.Valid() {
return codersdk.ValidationError{
Field: name,
Detail: fmt.Sprintf(invalidErr, value),
}
}

return codersdk.ValidationError{
Field: name,
Detail: fmt.Sprintf("Tag is invalid for some unknown reason. Please check the tag's value and key."),
}
}
Loading
Loading