Repository: klothoplatform/klotho
Pull request #573 (2023-05-04 23:22:29)

use klothoplatform/visualizer for generating the graph
Mostly what it says on the tin; a couple helper structs and small refactoring along the way.

One thing to note is `TestAllTypesHaveIcons`. It gets skipped unless you set an env var `KLOTHO_VIZ_BASE_URL`. Once klothoplatform/visualizer#10 gets merged, we may want to set this env var in our GH Action so that this test runs, hitting the public server.

In service of #499 

### Standard checks

- **Unit tests**: see above
- **Docs**: none needed
- **Backwards compatibility**: no issues



Patch:
diff --git a/pkg/cli/plugins.go b/pkg/cli/plugins.go
index 157e9b39..778bc55a 100644
--- a/pkg/cli/plugins.go
+++ b/pkg/cli/plugins.go
@@ -1,6 +1,8 @@
 package cli
 
 import (
+	"net/http"
+
 	compiler "github.com/klothoplatform/klotho/pkg/compiler"
 	"github.com/klothoplatform/klotho/pkg/config"
 	envvar "github.com/klothoplatform/klotho/pkg/env_var"
@@ -19,6 +21,7 @@ import (
 	"github.com/klothoplatform/klotho/pkg/provider"
 	"github.com/klothoplatform/klotho/pkg/provider/providers"
 	staticunit "github.com/klothoplatform/klotho/pkg/static_unit"
+	"github.com/klothoplatform/klotho/pkg/visualizer"
 )
 
 // PluginSetBuilder is a crude "plugin dependency" helper struct for managing the order of plugins via stages.
@@ -42,12 +45,18 @@ func (b *PluginSetBuilder) AddAll() error {
 		b.AddGo,
 		b.AddCSharp,
 		b.AddPulumi,
+		b.AddVisualizerPlugin,
 	} {
 		merr.Append(f())
 	}
 	return merr.ErrOrNil()
 }
 
+func (b *PluginSetBuilder) AddVisualizerPlugin() error {
+	b.IaC = append(b.IaC, visualizer.Plugin{Config: b.Cfg, Client: http.DefaultClient})
+	return nil
+}
+
 func (b *PluginSetBuilder) AddExecUnit() error {
 	b.AnalysisAndTransform = append(b.AnalysisAndTransform,
 		staticunit.StaticUnitSplit{Config: b.Cfg},
diff --git a/pkg/cli_config/envvar.go b/pkg/cli_config/envvar.go
index 07dd3af5..e79ca4e6 100644
--- a/pkg/cli_config/envvar.go
+++ b/pkg/cli_config/envvar.go
@@ -1,6 +1,9 @@
 package cli_config
 
-import "os"
+import (
+	"os"
+	"strings"
+)
 
 // EnvVar represents an environment variable, specified by its key name.
 // wrapper around  os.Getenv. This string's value is the env var key. Use GetOr to get its value, or a
@@ -17,3 +20,26 @@ func (s EnvVar) GetOr(defaultValue string) string {
 		return value
 	}
 }
+
+// GetBool returns the env var as a boolean.
+//
+// The value is false if the env var is:
+//
+//   - unset
+//   - the empty string ("")
+//   - "0" or "false" (case-insensitive)
+//
+// The value is true for all other values, including other false-looking strings like "no".
+func (s EnvVar) GetBool() bool {
+	switch strings.ToLower(os.Getenv(string(s))) {
+	case "", "0", "false":
+		return false
+	default:
+		return true
+	}
+}
+
+func (s EnvVar) IsSet() bool {
+	_, isSet := os.LookupEnv(string(s))
+	return isSet
+}
diff --git a/pkg/ioutil/write_to.go b/pkg/ioutil/write_to.go
new file mode 100644
index 00000000..d5f6e4c7
--- /dev/null
+++ b/pkg/ioutil/write_to.go
@@ -0,0 +1,69 @@
+package ioutil
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/klothoplatform/klotho/pkg/multierr"
+)
+
+type (
+	// WriteToHelper simplifies the use of a [io.Writer], and specifically in a way that helps you implement
+	// [io.WriterTo]. It does so by wrapping the Writer along with a reference to the count and err that WriterTo
+	// requires. When you write to the WriteToHelper, it either delegates to the Writer if there has not been an error,
+	// or else ignores the write if there has been. If it delegates, it also updates the count and err values.
+	WriteToHelper struct {
+		out   io.Writer
+		count *int64
+		err   *error
+	}
+)
+
+// NewWriteToHelper creates a new WriteToHelper which delegates to the given Writer and updates the given count and err
+// as needed.
+//
+// A good pattern for how to use this is:
+//
+//	func (wt *MyWriterTo) (w io.Writer) (count int64, err error)
+//		wh := ioutil.NewWriteToHelper(w, &count, &err)
+//		wh.Write("hello")
+//		wh.Write("world")
+//		return
+//	}
+//
+// The "wh" helper will delegate each of its writes to the "w" Writer, updating count and err as needed along the way.
+// If the Writer ever returns a non-nil error, subsequent write operations on the "wh" helper will be ignored.
+func NewWriteToHelper(out io.Writer, count *int64, err *error) WriteToHelper {
+	return WriteToHelper{
+		out:   out,
+		count: count,
+		err:   err,
+	}
+}
+
+func (w WriteToHelper) AddErr(err error) {
+	if *w.err == nil {
+		*w.err = err
+	} else if multiErr, ok := (*w.err).(multierr.Error); ok {
+		multiErr.Append(err)
+	} else {
+		multiErr = multierr.Error{}
+		multiErr.Append(*w.err)
+		multiErr.Append(err)
+		*w.err = multiErr
+	}
+}
+
+func (w WriteToHelper) Write(s string) {
+	w.Writef(`%s`, s)
+}
+
+func (w WriteToHelper) Writef(format string, a ...any) {
+	if *w.err != nil {
+		return
+	}
+
+	count, err := fmt.Fprintf(w.out, format, a...)
+	*w.count += int64(count)
+	*w.err = err
+}
diff --git a/pkg/provider/aws/resources/api_gateway.go b/pkg/provider/aws/resources/api_gateway.go
index 950ff5ba..1ee9fc67 100644
--- a/pkg/provider/aws/resources/api_gateway.go
+++ b/pkg/provider/aws/resources/api_gateway.go
@@ -175,10 +175,14 @@ func (link *VpcLink) KlothoConstructRef() []core.AnnotationKey {
 
 // Id returns the id of the cloud resource
 func (res *VpcLink) Id() core.ResourceId {
+	name := "<no target>"
+	if res.Target != nil {
+		name = res.Target.Id().String()
+	}
 	return core.ResourceId{
 		Provider: AWS_PROVIDER,
 		Type:     VPC_LINK_TYPE,
-		Name:     res.Target.Id().String(),
+		Name:     name,
 	}
 }
 
diff --git a/pkg/provider/aws/resources/vpc.go b/pkg/provider/aws/resources/vpc.go
index 993fc8ba..4ed49a1b 100644
--- a/pkg/provider/aws/resources/vpc.go
+++ b/pkg/provider/aws/resources/vpc.go
@@ -363,6 +363,23 @@ func NewSubnet(subnetName string, vpc *Vpc, cidrBlock string, subnetType string,
 	}
 }
 
+// IsPublic returns whether this Subnet is public within the resource graph. A subnet is public iff its upstream
+// RouteTable has a downstream InternetGateway.
+func (subnet *Subnet) IsPublic(dag *core.ResourceGraph) bool {
+	for _, upstreamFromSubnet := range dag.GetUpstreamResources(subnet) {
+		routeTable, ok := upstreamFromSubnet.(*RouteTable)
+		if !ok {
+			continue
+		}
+		for _, downstreamFromRouteTable := range dag.GetAllDownstreamResources(routeTable) {
+			if _, isIGW := downstreamFromRouteTable.(*InternetGateway); isIGW {
+				return true
+			}
+		}
+	}
+	return false
+}
+
 // KlothoConstructRef returns AnnotationKey of the klotho resource the cloud resource is correlated to
 func (subnet *Subnet) KlothoConstructRef() []core.AnnotationKey {
 	return subnet.ConstructsRef
diff --git a/pkg/visualizer/file.go b/pkg/visualizer/file.go
new file mode 100644
index 00000000..ce7b8fc4
--- /dev/null
+++ b/pkg/visualizer/file.go
@@ -0,0 +1,162 @@
+package visualizer
+
+import (
+	"fmt"
+	"io"
+	"reflect"
+	"strings"
+
+	"github.com/klothoplatform/klotho/pkg/core"
+	"github.com/klothoplatform/klotho/pkg/ioutil"
+	"gopkg.in/yaml.v3"
+)
+
+const indent = "    "
+
+type (
+	File struct {
+		AppName  string
+		Provider string
+		DAG      *core.ResourceGraph
+	}
+
+	// FetchPropertiesFunc is a function that takes a resource of some type, and returns some properties for it.
+	// This function also takes in the DAG, since some resources need that context to figure out their properties (for
+	// example, a subnet is private or public depending on how it's used).
+	FetchPropertiesFunc[K core.Resource] func(res K, dag *core.ResourceGraph) map[string]any
+
+	// propertiesFetcher takes a [core.Resource] and returns some properties for it. This is similar to
+	// FetchPropertiesFunc, except that the argument is always a core.Resource (as opposed to a specific subtype
+	// of core.Resource, as with FetchPropertiesFunc).
+	propertiesFetcher interface {
+		apply(res core.Resource, dag *core.ResourceGraph) map[string]any
+	}
+
+	// typedPropertiesFetcher is a propertiesFetcher that can also tell you which type it'll accept. The (unenforced)
+	// expectation is that if you pass in an element "e" to apply(...) such that reflect.TypeOf(e) != tpf.reflectType(),
+	// then the resulting map will always be nil.
+	//
+	// We use this as a convenience bridge: in the absence of wildcard generics in Go (e.g., "FetchPropertiesFunc[*]"),
+	// we can treat a "FetchPropertiesFunc[K]" as a typedPropertiesFetcher, and build a list of heterogeneous fetchers.
+	// Then, we can iterate through that list to build a [byTypePropertiesFetcher] that maps each FetchPropertiesFunc
+	// by its type.
+	typedPropertiesFetcher interface {
+		propertiesFetcher
+		reflectType() reflect.Type
+	}
+
+	byTypePropertiesFetcher map[reflect.Type]propertiesFetcher
+)
+
+func (f *File) Path() string {
+	return "topology.yaml"
+}
+
+func (f *File) Clone() core.File {
+	return f
+}
+
+func (f *File) WriteTo(w io.Writer) (n int64, err error) {
+	propFetcher := defaultPropertiesFetchers()
+	wh := ioutil.NewWriteToHelper(w, &n, &err)
+
+	wh.Writef("%s:\n", f.AppName)
+	wh.Writef("  provider: %s\n", f.Provider)
+	wh.Write("  resources:\n")
+
+	resourceIds, err := f.DAG.TopologicalSort()
+	if err != nil {
+		return
+	}
+	for i := len(resourceIds)/2 - 1; i >= 0; i-- {
+		opp := len(resourceIds) - 1 - i
+		resourceIds[i], resourceIds[opp] = resourceIds[opp], resourceIds[i]
+	}
+	for _, resourceId := range resourceIds {
+		resource := f.DAG.GetResourceByVertexId(resourceId)
+		key := f.KeyFor(resource)
+		if key == "" {
+			continue
+		}
+		wh.Writef(indent+"%s: # %s\n", key, resourceId)
+		properties := propFetcher.apply(resource, f.DAG)
+		if len(properties) > 0 {
+			writeYaml(properties, 2, wh)
+		}
+
+		edges := f.DAG.GetDownstreamDependencies(resource)
+		for _, edge := range edges {
+			src := f.KeyFor(edge.Source)
+			dst := f.KeyFor(edge.Destination)
+			if src != "" && dst != "" {
+				wh.Writef(indent+"%s -> %s:\n", src, dst)
+			}
+		}
+		wh.Write("\n")
+	}
+
+	return
+}
+
+func (f *File) KeyFor(res core.Resource) string {
+	resId := res.Id()
+	var providerInfo string
+	if resId.Provider != f.Provider {
+		providerInfo = resId.Provider + `:`
+	}
+	return strings.ToLower(fmt.Sprintf("%s%s/%s", providerInfo, TypeFor(res, f.DAG), resId.Name))
+}
+
+func writeYaml(e any, indentCount int, out ioutil.WriteToHelper) {
+	bs, err := yaml.Marshal(e)
+	if err != nil {
+		out.AddErr(err)
+		return
+	}
+	for _, line := range strings.Split(string(bs), "\n") {
+		if strings.TrimSpace(line) != "" {
+			for i := 0; i < indentCount; i++ {
+				out.Write(indent)
+			}
+		}
+		out.Write(line)
+		out.Write("\n")
+	}
+}
+
+func defaultPropertiesFetchers() byTypePropertiesFetcher {
+	var all []typedPropertiesFetcher
+	// BEGIN Add your property fetchers here
+	all = append(all, asApplier(subnetProperties))
+	// END
+
+	result := make(map[reflect.Type]propertiesFetcher, len(all))
+	for _, pf := range all {
+		result[pf.reflectType()] = pf
+	}
+
+	return result
+}
+
+func asApplier[K core.Resource](f FetchPropertiesFunc[K]) typedPropertiesFetcher {
+	return f
+}
+
+func (f FetchPropertiesFunc[K]) apply(res core.Resource, dag *core.ResourceGraph) map[string]any {
+	if res, ok := res.(K); ok {
+		return f(res, dag)
+	}
+	return nil
+}
+
+func (pf byTypePropertiesFetcher) apply(res core.Resource, dag *core.ResourceGraph) map[string]any {
+	resType := reflect.TypeOf(res)
+	if f := pf[resType]; f != nil {
+		return f.apply(res, dag)
+	}
+	return nil
+}
+
+func (c FetchPropertiesFunc[K]) reflectType() reflect.Type {
+	return reflect.TypeOf((*K)(nil)).Elem()
+}
diff --git a/pkg/visualizer/node_properties.go b/pkg/visualizer/node_properties.go
new file mode 100644
index 00000000..338a3b60
--- /dev/null
+++ b/pkg/visualizer/node_properties.go
@@ -0,0 +1,13 @@
+package visualizer
+
+import (
+	"github.com/klothoplatform/klotho/pkg/core"
+	"github.com/klothoplatform/klotho/pkg/provider/aws/resources"
+)
+
+func subnetProperties(res *resources.Subnet, dag *core.ResourceGraph) map[string]any {
+	return map[string]any{
+		"cidr_block": res.CidrBlock,
+		"public":     res.IsPublic(dag),
+	}
+}
diff --git a/pkg/visualizer/plugin.go b/pkg/visualizer/plugin.go
new file mode 100644
index 00000000..c6da437a
--- /dev/null
+++ b/pkg/visualizer/plugin.go
@@ -0,0 +1,111 @@
+package visualizer
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+
+	"github.com/klothoplatform/klotho/pkg/cli_config"
+	"github.com/klothoplatform/klotho/pkg/config"
+	"github.com/klothoplatform/klotho/pkg/core"
+	"go.uber.org/zap"
+)
+
+type Plugin struct {
+	Config *config.Application
+	Client *http.Client
+}
+
+type (
+	visApi struct {
+		client *http.Client
+		buf    bytes.Buffer
+	}
+
+	httpStatusBad int
+)
+
+// Name implements compiler.Plugin
+func (p Plugin) Name() string {
+	return "visualizer"
+}
+
+var visualizerBaseUrlEnv = cli_config.EnvVar("KLOTHO_VIZ_BASE_URL")
+var visualizerBaseUrl = visualizerBaseUrlEnv.GetOr("https://1kbh9oapga.execute-api.us-west-2.amazonaws.com/stage")
+var validateTypes = cli_config.EnvVar("KLOTHO_VIZ_VALIDATE_TYPES").GetBool()
+
+func (a *visApi) request(method string, path string, contentType string, accept string, f io.WriterTo) ([]byte, error) {
+	a.buf.Reset()
+	_, err := f.WriteTo(&a.buf)
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(method, visualizerBaseUrl+`/api/v1/`+path, &a.buf)
+	if err != nil {
+		return nil, err
+	}
+	if contentType != "" {
+		req.Header.Set("Content-Type", contentType)
+	}
+	if accept != "" {
+		req.Header.Set("Accept", accept)
+	}
+
+	resp, err := a.client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	a.buf.Reset()
+	_, err = a.buf.ReadFrom(resp.Body)
+	if err != nil && resp.StatusCode < 200 || resp.StatusCode > 299 {
+		err = httpStatusBad(resp.StatusCode)
+	}
+	return a.buf.Bytes(), err
+}
+
+// Translate implements compiler.IaCPlugin - although it's not strictly an IaC plugin, it uses the same API
+func (p Plugin) Translate(dag *core.ResourceGraph) ([]core.File, error) {
+	api := visApi{client: p.Client}
+
+	if validateTypes {
+		types := TypesChecker{DAG: dag}
+
+		if resp, err := api.request(http.MethodGet, `validate-types`, `application/text`, ``, types); err != nil {
+			if badStatus, isBadStatus := err.(httpStatusBad); isBadStatus {
+				unknowns := strings.ReplaceAll(string(resp), "\n", "\n•  ")
+				zap.S().Warnf("Failed to validate all types in visualizer (%d). %s", badStatus, unknowns)
+			} else {
+				zap.S().With(zap.Error(err)).Warnf("Failed to validate types in visualizer: %v", err)
+			}
+		}
+	}
+
+	spec := &File{
+		AppName:  p.Config.AppName,
+		Provider: p.Config.Provider,
+		DAG:      dag,
+	}
+
+	resp, err := api.request(http.MethodPost, `generate-infra-diagram`, "application/yaml", "image/png", spec)
+	if err != nil {
+		return nil, err
+	}
+
+	diagram := &core.RawFile{
+		FPath:   "diagram.png",
+		Content: resp,
+	}
+
+	return []core.File{
+		spec,
+		diagram,
+	}, nil
+}
+
+func (h httpStatusBad) Error() string {
+	return fmt.Sprintf("visualizer returned status code %d", h)
+}
diff --git a/pkg/visualizer/resource_types.go b/pkg/visualizer/resource_types.go
new file mode 100644
index 00000000..3d3a08c0
--- /dev/null
+++ b/pkg/visualizer/resource_types.go
@@ -0,0 +1,23 @@
+package visualizer
+
+import (
+	"github.com/klothoplatform/klotho/pkg/core"
+	"github.com/klothoplatform/klotho/pkg/provider/aws/resources"
+)
+
+func TypeFor(res core.Resource, dag *core.ResourceGraph) string {
+	resType := res.Id().Type
+	// Important: if you update this switch, also update all_types_test.go's typeNamesForResource
+	switch res := res.(type) {
+	case *resources.Subnet:
+		resType = "subnet" // not "vpc_subnet"
+	case *resources.VpcEndpoint:
+		switch res.VpcEndpointType {
+		case "Interface":
+			resType = "vpc_endpoint_interface"
+		case "Gateway":
+			resType = "vpc_endpoint_gateway"
+		}
+	}
+	return resType
+}
diff --git a/pkg/visualizer/resource_types_checker.go b/pkg/visualizer/resource_types_checker.go
new file mode 100644
index 00000000..c873a784
--- /dev/null
+++ b/pkg/visualizer/resource_types_checker.go
@@ -0,0 +1,23 @@
+package visualizer
+
+import (
+	"io"
+
+	"github.com/klothoplatform/klotho/pkg/core"
+	"github.com/klothoplatform/klotho/pkg/ioutil"
+)
+
+type (
+	TypesChecker struct {
+		DAG *core.ResourceGraph
+	}
+)
+
+func (tc TypesChecker) WriteTo(w io.Writer) (n int64, err error) {
+	wh := ioutil.NewWriteToHelper(w, &n, &err)
+
+	for _, res := range tc.DAG.ListResources() {
+		wh.Writef("%s\n", TypeFor(res, tc.DAG))
+	}
+	return
+}


Test patch:
diff --git a/pkg/core/coretesting/all_resources_list.go b/pkg/core/coretesting/all_resources_list.go
index 922280db..9705890a 100644
--- a/pkg/core/coretesting/all_resources_list.go
+++ b/pkg/core/coretesting/all_resources_list.go
@@ -6,6 +6,7 @@ import (
 	"reflect"
 	"runtime"
 	"strings"
+	"testing"
 
 	"github.com/klothoplatform/klotho/pkg/core"
 	"github.com/pkg/errors"
@@ -30,6 +31,8 @@ type (
 		Pkg  string
 		Name string
 	}
+
+	TypeRefSet map[TypeRef]struct{}
 )
 
 // FindAllResources returns the TypeRefs for each of the resources you pass in, as well as for any other resources in
@@ -124,6 +127,24 @@ func FindAllResources(a *assert.Assertions, from []core.Resource) []TypeRef {
 	return results
 }
 
+func (ts TypeRefSet) Add(e any) {
+	eType := reflect.TypeOf(e)
+	for eType.Kind() == reflect.Pointer {
+		eType = eType.Elem()
+	}
+	ts[TypeRef{Pkg: eType.PkgPath(), Name: eType.Name()}] = struct{}{}
+}
+
+func (ts TypeRefSet) Check(t *testing.T, required TypeRef, msg string) {
+	assert := assert.New(t)
+
+	assert.Contains(
+		ts,
+		required,
+		msg)
+
+}
+
 // getTypesInPackage finds all types within a package (which may be "..."-ed).
 func getTypesInPackage(packageName string) (map[TypeRef]Methods, error) {
 	config := &packages.Config{Mode: packages.NeedName | packages.NeedTypes | packages.NeedTypesInfo}
diff --git a/pkg/infra/iac2/inputs_outputs_test.go b/pkg/infra/iac2/inputs_outputs_test.go
index 81079ac8..8c4280fd 100644
--- a/pkg/infra/iac2/inputs_outputs_test.go
+++ b/pkg/infra/iac2/inputs_outputs_test.go
@@ -41,14 +41,14 @@ func TestKnownTemplates(t *testing.T) {
 	)
 
 	tp := standardTemplatesProvider()
-	testedTypes := make(map[coretesting.TypeRef]struct{})
+	testedTypes := make(coretesting.TypeRefSet)
 	for _, res := range allResources {
+		testedTypes.Add(res)
 		baseResourceType := reflect.TypeOf(res)
 		resType := baseResourceType
 		for resType.Kind() == reflect.Pointer {
 			resType = resType.Elem()
 		}
-		testedTypes[coretesting.TypeRef{Pkg: resType.PkgPath(), Name: resType.Name()}] = struct{}{}
 		t.Run(resType.String(), func(t *testing.T) {
 			var tmpl ResourceCreationTemplate
 
@@ -123,11 +123,7 @@ func TestKnownTemplates(t *testing.T) {
 	t.Run("all types tested", func(t *testing.T) {
 		for _, ref := range coretesting.FindAllResources(assert.New(t), allResources) {
 			t.Run(ref.Name, func(t *testing.T) {
-				assert := assert.New(t)
-				assert.Contains(
-					testedTypes,
-					ref,
-					`struct implements core.Resource but isn't tested; add it to this test's '"allResources" var`)
+				testedTypes.Check(t, ref, `struct implements core.Resource but isn't tested; add it to this test's '"allResources" var`)
 			})
 		}
 	})
diff --git a/pkg/ioutil/write_to_test.go b/pkg/ioutil/write_to_test.go
new file mode 100644
index 00000000..07b051a1
--- /dev/null
+++ b/pkg/ioutil/write_to_test.go
@@ -0,0 +1,65 @@
+package ioutil
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGoodWrite(t *testing.T) {
+	assert := assert.New(t)
+
+	var count int64
+	var err error
+	buf := strings.Builder{}
+
+	helper := NewWriteToHelper(&buf, &count, &err)
+
+	helper.Write("hello") // 5 chars
+	assert.NoError(err)
+	assert.Equal(int64(5), count)
+	assert.Equal("hello", buf.String())
+
+	helper.Write(", world") // 7 chars
+	assert.NoError(err)
+	assert.Equal(int64(5+7), count)
+	assert.Equal("hello, world", buf.String())
+}
+
+func TestBadWrite(t *testing.T) {
+	assert := assert.New(t)
+
+	var count int64
+	var err error
+	buf := dummyBuffer{}
+
+	helper := NewWriteToHelper(&buf, &count, &err)
+
+	helper.Write("hello") // 5 chars
+	assert.NoError(err)
+	assert.Equal(int64(5), count)
+
+	buf.failAfterWrite = true
+	helper.Write(", world") // 7 chars
+	assert.Error(err)
+	assert.Equal(int64(5+7), count) // note: the writer "wrote" the 7 chars before it failed
+
+	helper.Write(", and then some!")
+	assert.Error(err)
+	assert.Equal(int64(5+7), count) // note: no new elements written
+
+}
+
+type dummyBuffer struct {
+	failAfterWrite bool
+}
+
+func (d *dummyBuffer) Write(p []byte) (n int, err error) {
+	n += len(p)
+	if d.failAfterWrite {
+		err = fmt.Errorf("some error")
+	}
+	return
+}
diff --git a/pkg/visualizer/all_types_test.go b/pkg/visualizer/all_types_test.go
new file mode 100644
index 00000000..d490ce42
--- /dev/null
+++ b/pkg/visualizer/all_types_test.go
@@ -0,0 +1,68 @@
+package visualizer
+
+import (
+	"bytes"
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/klothoplatform/klotho/pkg/core"
+	"github.com/klothoplatform/klotho/pkg/core/coretesting"
+	"github.com/klothoplatform/klotho/pkg/provider/aws/resources"
+	"github.com/stretchr/testify/assert"
+)
+
+// TestAllTypesHaveIcons is an integration test — not a unit test!
+//
+// To run it, set the env var KLOTHO_VIZ_BASE_URL to a visualizer service endpoint. For local testing, this is probably
+// "http://localhost:3000". If the env var isn't set, this test will be skipped.
+func TestAllTypesHaveIcons(t *testing.T) {
+	if !visualizerBaseUrlEnv.IsSet() {
+		t.Skipf(`Skipping because %s isn't set`, visualizerBaseUrlEnv)
+		return
+	}
+	allResources := resources.ListAll()
+	testedTypes := make(coretesting.TypeRefSet)
+
+	api := visApi{client: http.DefaultClient}
+	for _, res := range allResources {
+		provider, typeNames := typeNamesForResource(res)
+		for _, typeName := range typeNames {
+			t.Run(fmt.Sprintf("%s:%s", provider, typeName), func(t *testing.T) {
+				testedTypes.Add(res)
+				assert := assert.New(t)
+				typeNameBuf := bytes.Buffer{}
+				typeNameBuf.WriteString(typeName)
+				_, err := api.request(http.MethodGet, `validate-types`, `application/text`, ``, &typeNameBuf)
+				assert.NoError(err)
+			})
+		}
+	}
+
+	t.Run("all types tested", func(t *testing.T) {
+		for _, ref := range coretesting.FindAllResources(assert.New(t), allResources) {
+			t.Run(ref.Name, func(t *testing.T) {
+				testedTypes.Check(t, ref, `struct implements core.Resource but isn't tested; add it to this test's '"allResources" var`)
+			})
+		}
+	})
+
+}
+
+// typeNamesForResource returns all the possible type names for this resource, as well as the resource's provider (which
+// we assume is always the same for a given resource). Keep this func in sync with resource_types.go's TypeFor.
+func typeNamesForResource(res core.Resource) (string, []string) {
+	resId := res.Id()
+
+	var typeNames []string
+	// keep this in sync with resource_types.go
+	switch res.(type) {
+	case *resources.Subnet:
+		typeNames = append(typeNames, "subnet") // not "vpc_subnet"
+	case *resources.VpcEndpoint:
+		typeNames = append(typeNames, "vpc_endpoint_interface", "vpc_endpoint_gateway")
+	default:
+		typeNames = append(typeNames, resId.Type)
+	}
+	return resId.Provider, typeNames
+}