From 05342c6f5f9b9e24911b56cd25e700366aa8997c Mon Sep 17 00:00:00 2001 From: chrisghill Date: Thu, 6 Aug 2026 13:14:02 -0600 Subject: [PATCH 01/19] first pass --- cmd/resource_type.go | 256 +++++++++++++++---- docs/generated/mass_resource-type.md | 3 + docs/generated/mass_resource-type_convert.md | 53 ++++ docs/generated/mass_resource-type_create.md | 55 ++++ docs/generated/mass_resource-type_get.md | 1 + docs/generated/mass_resource-type_publish.md | 26 +- docs/generated/mass_resource-type_pull.md | 50 ++++ docs/helpdocs/type/convert.md | 24 ++ docs/helpdocs/type/create.md | 21 ++ docs/helpdocs/type/publish.md | 24 +- docs/helpdocs/type/pull.md | 20 ++ internal/bundle/publish.go | 168 +----------- internal/bundle/publish_test.go | 12 +- internal/bundle/pull.go | 19 -- internal/commands/bundle/publish.go | 12 +- internal/commands/bundle/pull.go | 6 +- internal/commands/instance/export.go | 6 +- internal/oci/oci.go | 188 ++++++++++++++ internal/{bundle => oci}/pull_test.go | 8 +- internal/resourcetype/build.go | 32 ++- internal/resourcetype/convert.go | 219 ++++++++++++++++ internal/resourcetype/convert_test.go | 100 ++++++++ internal/resourcetype/delete.go | 22 +- internal/resourcetype/delete_test.go | 70 ----- internal/resourcetype/keep_test.go | 39 +++ internal/resourcetype/publish.go | 165 ++++++++++-- internal/resourcetype/publish_test.go | 106 +++----- internal/resourcetype/pull.go | 78 ++++++ 28 files changed, 1351 insertions(+), 432 deletions(-) create mode 100644 docs/generated/mass_resource-type_convert.md create mode 100644 docs/generated/mass_resource-type_create.md create mode 100644 docs/generated/mass_resource-type_pull.md create mode 100644 docs/helpdocs/type/convert.md create mode 100644 docs/helpdocs/type/create.md create mode 100644 docs/helpdocs/type/pull.md delete mode 100644 internal/bundle/pull.go create mode 100644 internal/oci/oci.go rename internal/{bundle => oci}/pull_test.go (94%) create mode 100644 internal/resourcetype/convert.go create mode 100644 internal/resourcetype/convert_test.go delete mode 100644 internal/resourcetype/delete_test.go create mode 100644 internal/resourcetype/keep_test.go create mode 100644 internal/resourcetype/pull.go diff --git a/cmd/resource_type.go b/cmd/resource_type.go index b5b47144..36d537f1 100644 --- a/cmd/resource_type.go +++ b/cmd/resource_type.go @@ -6,8 +6,10 @@ import ( "context" "embed" "encoding/json" + "errors" "fmt" "os" + "path/filepath" "strings" "text/template" @@ -17,6 +19,7 @@ import ( "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/mass/internal/resourcetype" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/ocirepos" "github.com/spf13/cobra" ) @@ -32,6 +35,16 @@ func NewCmdType() *cobra.Command { Aliases: []string{"rt", "type", "res-type", "definition", "artifact-definition", "artdef", "def"}, } + typeCreateCmd := &cobra.Command{ + Use: "create ", + Short: "Create a new resource type OCI repository in your organization's catalog", + Long: helpdocs.MustRender("type/create"), + Example: `mass resource-type create my-resource-type -a owner=data,service=database`, + Args: cobra.ExactArgs(1), + RunE: runTypeCreate, + } + typeCreateCmd.Flags().StringToStringP("attributes", "a", nil, "Custom attributes (e.g. -a owner=data,service=database)") + typeGetCmd := &cobra.Command{ Use: "get [resource-type]", Short: "Get a resource type from Massdriver", @@ -40,6 +53,7 @@ func NewCmdType() *cobra.Command { RunE: runTypeGet, } typeGetCmd.Flags().StringP("output", "o", "text", "Output format (text or json)") + typeGetCmd.Flags().Bool("schema", false, "With -o json, output only the resolved JSON schema") typeListCmd := &cobra.Command{ Use: "list", @@ -51,12 +65,24 @@ func NewCmdType() *cobra.Command { typeListCmd.Flags().StringP("output", "o", "table", "Output format (table, json)") typePublishCmd := &cobra.Command{ - Use: "publish [resource-type file]", - Short: "Publish a resource type to Massdriver", - Long: helpdocs.MustRender("type/publish"), + Use: "publish [path]", + Aliases: []string{"push"}, + Short: "Publish a resource type to Massdriver", + Long: helpdocs.MustRender("type/publish"), + Args: cobra.MaximumNArgs(1), + RunE: runTypePublish, + } + + typePullCmd := &cobra.Command{ + Use: "pull ", + Short: "Pull a resource type from Massdriver to a local directory", + Long: helpdocs.MustRender("type/pull"), Args: cobra.ExactArgs(1), - RunE: runTypePublish, + RunE: runTypePull, } + typePullCmd.Flags().StringP("directory", "d", "", "Directory to output the resource type. Defaults to the resource type name.") + typePullCmd.Flags().BoolP("force", "f", false, "Force pull even if the directory already exists. This will overwrite existing files.") + typePullCmd.Flags().StringP("version", "v", "latest", "Resource type version or release channel") typeDeleteCmd := &cobra.Command{ Use: "delete [resource-type]", @@ -67,14 +93,45 @@ func NewCmdType() *cobra.Command { } typeDeleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + typeConvertCmd := &cobra.Command{ + Use: "convert ", + Short: "Convert a raw JSON schema resource type into a massdriver.yaml", + Long: helpdocs.MustRender("type/convert"), + Args: cobra.ExactArgs(1), + RunE: runTypeConvert, + } + typeConvertCmd.Flags().StringP("output", "o", "", "Path to write the massdriver.yaml (default: alongside the input file)") + typeConvertCmd.Flags().BoolP("force", "f", false, "Overwrite existing files") + + typeCmd.AddCommand(typeCreateCmd) typeCmd.AddCommand(typeGetCmd) - typeCmd.AddCommand(typePublishCmd) typeCmd.AddCommand(typeListCmd) + typeCmd.AddCommand(typePublishCmd) + typeCmd.AddCommand(typePullCmd) typeCmd.AddCommand(typeDeleteCmd) + typeCmd.AddCommand(typeConvertCmd) return typeCmd } +func runTypeCreate(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + name := args[0] + attrs, err := cmd.Flags().GetStringToString("attributes") + if err != nil { + return err + } + cmd.SilenceUsage = true + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) + } + + return createOciRepoCommon(ctx, mdClient, name, string(ocirepos.ArtifactTypeResourceType), attrs) +} + func runTypeGet(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -83,8 +140,16 @@ func runTypeGet(cmd *cobra.Command, args []string) error { if err != nil { return err } + schemaOnly, err := cmd.Flags().GetBool("schema") + if err != nil { + return err + } cmd.SilenceUsage = true + if schemaOnly && outputFormat != "json" { + return errors.New("--schema requires -o json") + } + mdClient, err := massdriver.NewClient() if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) @@ -97,14 +162,17 @@ func runTypeGet(cmd *cobra.Command, args []string) error { switch outputFormat { case "json": - jsonBytes, marshalErr := json.MarshalIndent(rt, "", " ") + payload := any(rt) + if schemaOnly { + payload = rt.Schema + } + jsonBytes, marshalErr := json.MarshalIndent(payload, "", " ") if marshalErr != nil { return fmt.Errorf("failed to marshal resource type to JSON: %w", marshalErr) } fmt.Println(string(jsonBytes)) case "text": - err = renderType(rt) - if err != nil { + if err = renderType(rt); err != nil { return err } default: @@ -117,7 +185,10 @@ func runTypeGet(cmd *cobra.Command, args []string) error { func runTypePublish(cmd *cobra.Command, args []string) error { ctx := context.Background() - defFile := args[0] + path := "." + if len(args) > 0 { + path = args[0] + } cmd.SilenceUsage = true mdClient, err := massdriver.NewClient() @@ -125,13 +196,56 @@ func runTypePublish(cmd *cobra.Command, args []string) error { return fmt.Errorf("error initializing massdriver client: %w", err) } - artDef, publishErr := resourcetype.Publish(ctx, mdClient, defFile) + name, version, publishErr := resourcetype.Publish(ctx, mdClient, path) if publishErr != nil { return fmt.Errorf("error publishing resource type: %w", publishErr) } - fmt.Printf("Resource type %s published successfully!\n", prettylogs.Underline(artDef.Name)) + fmt.Printf("Resource type %s:%s published successfully!\n", prettylogs.Underline(name), version) + return nil +} + +func runTypePull(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + name := args[0] + directory, _ := cmd.Flags().GetString("directory") + if directory == "" { + directory = name + } + force, _ := cmd.Flags().GetBool("force") + version, _ := cmd.Flags().GetString("version") + cmd.SilenceUsage = true + + // Warn before overwriting an existing resource type in the target directory. + mdYamlPath := filepath.Join(directory, "massdriver.yaml") + if _, statErr := os.Stat(mdYamlPath); statErr == nil && !force { + fmt.Printf("Resource type already exists at %s. Continuing will overwrite its contents. Continue? (y/N): ", mdYamlPath) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println("Resource type pull aborted!") + return nil + } + } + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) + } + tag, digest, pullErr := resourcetype.Pull(ctx, mdClient, name, version, directory) + if pullErr != nil { + return fmt.Errorf("error pulling resource type: %w", pullErr) + } + + fmt.Printf("Resource type %s:%s pulled successfully to %s (Digest: %s)\n", + prettylogs.Underline(name), + prettylogs.Underline(tag), + prettylogs.Underline(directory), + prettylogs.Underline(digest), + ) return nil } @@ -174,6 +288,82 @@ func runTypeList(cmd *cobra.Command, args []string) error { return nil } +func runTypeDelete(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + name := args[0] + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + cmd.SilenceUsage = true + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) + } + + // Confirm the repository exists (and surface its canonical name) before prompting. + repo, getErr := mdClient.OciRepos.Get(ctx, name) + if getErr != nil { + return fmt.Errorf("error getting resource type: %w", getErr) + } + + // Fail before the confirmation prompt if the repo is immutable (has published + // versions) — no point making the user type the name for a delete that can't + // succeed. resourcetype.Delete re-checks to guard against a version being + // published during the prompt. + if len(repo.Tags) > 0 { + return fmt.Errorf("resource type %s has published versions and is immutable; its repository cannot be deleted", repo.Name) + } + + if !force { + fmt.Printf("WARNING: This will permanently delete resource type `%s`.\n", repo.Name) + fmt.Printf("Type `%s` to confirm deletion: ", repo.Name) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(answer) + + if answer != repo.Name { + fmt.Println("Deletion cancelled.") + return nil + } + } + + deleted, deleteErr := resourcetype.Delete(ctx, mdClient, name) + if deleteErr != nil { + return fmt.Errorf("error deleting resource type: %w", deleteErr) + } + + fmt.Printf("Resource type %s deleted successfully!\n", prettylogs.Underline(deleted.Name)) + return nil +} + +func runTypeConvert(cmd *cobra.Command, args []string) error { + schemaPath := args[0] + output, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + cmd.SilenceUsage = true + + result, convertErr := resourcetype.Convert(schemaPath, output, force) + if convertErr != nil { + return fmt.Errorf("error converting resource type: %w", convertErr) + } + + fmt.Printf("Wrote %s\n", prettylogs.Underline(result.MassdriverYAML)) + for _, f := range result.ExtraFiles { + fmt.Printf("Wrote %s\n", prettylogs.Underline(f)) + } + fmt.Println(prettylogs.Orange("Remember to set a real `version` in the massdriver.yaml before publishing.")) + return nil +} + func renderType(restype *resourcetype.ResourceType) error { schemaJSON, err := json.MarshalIndent(restype.Schema, "", " ") if err != nil { @@ -218,47 +408,3 @@ func renderType(restype *resourcetype.ResourceType) error { fmt.Print(out) return nil } - -func runTypeDelete(cmd *cobra.Command, args []string) error { - ctx := context.Background() - - typeName := args[0] - force, err := cmd.Flags().GetBool("force") - if err != nil { - return err - } - cmd.SilenceUsage = true - - mdClient, err := massdriver.NewClient() - if err != nil { - return fmt.Errorf("error initializing massdriver client: %w", err) - } - - // Get resource type details for confirmation - rt, err := resourcetype.Get(ctx, mdClient, typeName) - if err != nil { - return fmt.Errorf("error getting resource type: %w", err) - } - - // Prompt for confirmation - requires typing the resource type name unless --force is used - if !force { - fmt.Printf("WARNING: This will permanently delete resource type `%s`.\n", rt.Name) - fmt.Printf("Type `%s` to confirm deletion: ", rt.Name) - reader := bufio.NewReader(os.Stdin) - answer, _ := reader.ReadString('\n') - answer = strings.TrimSpace(answer) - - if answer != rt.Name { - fmt.Println("Deletion cancelled.") - return nil - } - } - - deleted, deleteErr := resourcetype.Delete(ctx, mdClient, typeName) - if deleteErr != nil { - return fmt.Errorf("error deleting resource type: %w", deleteErr) - } - - fmt.Printf("Resource type %s deleted successfully!\n", prettylogs.Underline(deleted.Name)) - return nil -} diff --git a/docs/generated/mass_resource-type.md b/docs/generated/mass_resource-type.md index 5b3b7008..1986a29c 100644 --- a/docs/generated/mass_resource-type.md +++ b/docs/generated/mass_resource-type.md @@ -30,7 +30,10 @@ Resource types are used to: ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI +* [mass resource-type convert](/cli/commands/mass_resource-type_convert) - Convert a raw JSON schema resource type into a massdriver.yaml +* [mass resource-type create](/cli/commands/mass_resource-type_create) - Create a new resource type OCI repository in your organization's catalog * [mass resource-type delete](/cli/commands/mass_resource-type_delete) - Delete a resource type from Massdriver * [mass resource-type get](/cli/commands/mass_resource-type_get) - Get a resource type from Massdriver * [mass resource-type list](/cli/commands/mass_resource-type_list) - List resource types * [mass resource-type publish](/cli/commands/mass_resource-type_publish) - Publish a resource type to Massdriver +* [mass resource-type pull](/cli/commands/mass_resource-type_pull) - Pull a resource type from Massdriver to a local directory diff --git a/docs/generated/mass_resource-type_convert.md b/docs/generated/mass_resource-type_convert.md new file mode 100644 index 00000000..4a1a4243 --- /dev/null +++ b/docs/generated/mass_resource-type_convert.md @@ -0,0 +1,53 @@ +--- +id: mass_resource-type_convert.md +slug: /cli/commands/mass_resource-type_convert +title: Mass Resource-Type Convert +sidebar_label: Mass Resource-Type Convert +--- +## mass resource-type convert + +Convert a raw JSON schema resource type into a massdriver.yaml + +### Synopsis + +# Convert Resource Type + +Converts a raw JSON (or YAML) resource type schema into a `massdriver.yaml`. +Inlined instruction and export content is extracted back out into referenced +files alongside the generated `massdriver.yaml`. + +A placeholder `version` is written into the output — set a real version before +publishing. + +## Usage + +```bash +mass resource-type convert [flags] +``` + +## Examples + +```bash +# Convert a raw JSON schema, writing massdriver.yaml alongside it +mass resource-type convert ./my-resource-type.json + +# Convert to a specific output path, overwriting if it exists +mass resource-type convert ./my-resource-type.json --output ./rt/massdriver.yaml --force +``` + + +``` +mass resource-type convert [flags] +``` + +### Options + +``` + -f, --force Overwrite existing files + -h, --help help for convert + -o, --output string Path to write the massdriver.yaml (default: alongside the input file) +``` + +### SEE ALSO + +* [mass resource-type](/cli/commands/mass_resource-type) - Resource type management diff --git a/docs/generated/mass_resource-type_create.md b/docs/generated/mass_resource-type_create.md new file mode 100644 index 00000000..55b16fb2 --- /dev/null +++ b/docs/generated/mass_resource-type_create.md @@ -0,0 +1,55 @@ +--- +id: mass_resource-type_create.md +slug: /cli/commands/mass_resource-type_create +title: Mass Resource-Type Create +sidebar_label: Mass Resource-Type Create +--- +## mass resource-type create + +Create a new resource type OCI repository in your organization's catalog + +### Synopsis + +# Create Resource Type + +Creates a new resource type OCI repository in your organization's catalog. The +repository starts empty; publish a version to it with +`mass resource-type publish`. + +## Usage + +```bash +mass resource-type create +``` + +## Examples + +```bash +# Create a resource type repository +mass resource-type create my-resource-type + +# Create with custom attributes +mass resource-type create my-resource-type -a owner=data,service=database +``` + + +``` +mass resource-type create [flags] +``` + +### Examples + +``` +mass resource-type create my-resource-type -a owner=data,service=database +``` + +### Options + +``` + -a, --attributes stringToString Custom attributes (e.g. -a owner=data,service=database) (default []) + -h, --help help for create +``` + +### SEE ALSO + +* [mass resource-type](/cli/commands/mass_resource-type) - Resource type management diff --git a/docs/generated/mass_resource-type_get.md b/docs/generated/mass_resource-type_get.md index 61ba5f4e..11149f26 100644 --- a/docs/generated/mass_resource-type_get.md +++ b/docs/generated/mass_resource-type_get.md @@ -45,6 +45,7 @@ mass resource-type get [resource-type] [flags] ``` -h, --help help for get -o, --output string Output format (text or json) (default "text") + --schema With -o json, output only the resolved JSON schema ``` ### SEE ALSO diff --git a/docs/generated/mass_resource-type_publish.md b/docs/generated/mass_resource-type_publish.md index 842e0704..753b7bfe 100644 --- a/docs/generated/mass_resource-type_publish.md +++ b/docs/generated/mass_resource-type_publish.md @@ -12,27 +12,39 @@ Publish a resource type to Massdriver # Publish Resource Type -Publishes a new or updated resource type to Massdriver. Supports JSON or YAML formats. +Publishes a resource type to your organization's catalog as an OCI artifact. + +The resource type is authored as a `massdriver.yaml` file, which must include a +`version` field. Publishing is immutable: a version that already exists cannot be +republished. + +Raw JSON schema publishing is no longer supported. If you have a raw JSON schema, +convert it first with `mass resource-type convert`. ## Usage ```bash -mass resource-type publish +mass resource-type publish [path] ``` +`path` is a directory containing a `massdriver.yaml` (defaults to the current +directory). Only `massdriver.yaml`, `readme`, `changelog`, icon files, and the +`instructions/` and `exports/` directories referenced by the `massdriver.yaml` +are included in the published artifact. + ## Examples ```bash -# Publish a resource type from a JSON file -mass resource-type publish my-resource-type.json +# Publish the resource type in the current directory +mass resource-type publish -# Publish a resource type from a YAML file -mass resource-type publish my-resource-type.yaml +# Publish a resource type from a specific directory +mass resource-type publish ./my-resource-type ``` ``` -mass resource-type publish [resource-type file] [flags] +mass resource-type publish [path] [flags] ``` ### Options diff --git a/docs/generated/mass_resource-type_pull.md b/docs/generated/mass_resource-type_pull.md new file mode 100644 index 00000000..dbc28b73 --- /dev/null +++ b/docs/generated/mass_resource-type_pull.md @@ -0,0 +1,50 @@ +--- +id: mass_resource-type_pull.md +slug: /cli/commands/mass_resource-type_pull +title: Mass Resource-Type Pull +sidebar_label: Mass Resource-Type Pull +--- +## mass resource-type pull + +Pull a resource type from Massdriver to a local directory + +### Synopsis + +# Pull Resource Type + +Pulls a published resource type from your organization's catalog into a local +directory. + +## Usage + +```bash +mass resource-type pull [flags] +``` + +## Examples + +```bash +# Pull the latest version into a directory named after the resource type +mass resource-type pull my-resource-type + +# Pull a specific version into a specific directory +mass resource-type pull my-resource-type --version 1.2.0 --directory ./out +``` + + +``` +mass resource-type pull [flags] +``` + +### Options + +``` + -d, --directory string Directory to output the resource type. Defaults to the resource type name. + -f, --force Force pull even if the directory already exists. This will overwrite existing files. + -h, --help help for pull + -v, --version string Resource type version or release channel (default "latest") +``` + +### SEE ALSO + +* [mass resource-type](/cli/commands/mass_resource-type) - Resource type management diff --git a/docs/helpdocs/type/convert.md b/docs/helpdocs/type/convert.md new file mode 100644 index 00000000..e9940ef5 --- /dev/null +++ b/docs/helpdocs/type/convert.md @@ -0,0 +1,24 @@ +# Convert Resource Type + +Converts a raw JSON (or YAML) resource type schema into a `massdriver.yaml`. +Inlined instruction and export content is extracted back out into referenced +files alongside the generated `massdriver.yaml`. + +A placeholder `version` is written into the output — set a real version before +publishing. + +## Usage + +```bash +mass resource-type convert [flags] +``` + +## Examples + +```bash +# Convert a raw JSON schema, writing massdriver.yaml alongside it +mass resource-type convert ./my-resource-type.json + +# Convert to a specific output path, overwriting if it exists +mass resource-type convert ./my-resource-type.json --output ./rt/massdriver.yaml --force +``` diff --git a/docs/helpdocs/type/create.md b/docs/helpdocs/type/create.md new file mode 100644 index 00000000..397e2365 --- /dev/null +++ b/docs/helpdocs/type/create.md @@ -0,0 +1,21 @@ +# Create Resource Type + +Creates a new resource type OCI repository in your organization's catalog. The +repository starts empty; publish a version to it with +`mass resource-type publish`. + +## Usage + +```bash +mass resource-type create +``` + +## Examples + +```bash +# Create a resource type repository +mass resource-type create my-resource-type + +# Create with custom attributes +mass resource-type create my-resource-type -a owner=data,service=database +``` diff --git a/docs/helpdocs/type/publish.md b/docs/helpdocs/type/publish.md index dd52e9af..0fde61b7 100644 --- a/docs/helpdocs/type/publish.md +++ b/docs/helpdocs/type/publish.md @@ -1,19 +1,31 @@ # Publish Resource Type -Publishes a new or updated resource type to Massdriver. Supports JSON or YAML formats. +Publishes a resource type to your organization's catalog as an OCI artifact. + +The resource type is authored as a `massdriver.yaml` file, which must include a +`version` field. Publishing is immutable: a version that already exists cannot be +republished. + +Raw JSON schema publishing is no longer supported. If you have a raw JSON schema, +convert it first with `mass resource-type convert`. ## Usage ```bash -mass resource-type publish +mass resource-type publish [path] ``` +`path` is a directory containing a `massdriver.yaml` (defaults to the current +directory). Only `massdriver.yaml`, `readme`, `changelog`, icon files, and the +`instructions/` and `exports/` directories referenced by the `massdriver.yaml` +are included in the published artifact. + ## Examples ```bash -# Publish a resource type from a JSON file -mass resource-type publish my-resource-type.json +# Publish the resource type in the current directory +mass resource-type publish -# Publish a resource type from a YAML file -mass resource-type publish my-resource-type.yaml +# Publish a resource type from a specific directory +mass resource-type publish ./my-resource-type ``` diff --git a/docs/helpdocs/type/pull.md b/docs/helpdocs/type/pull.md new file mode 100644 index 00000000..90ffff58 --- /dev/null +++ b/docs/helpdocs/type/pull.md @@ -0,0 +1,20 @@ +# Pull Resource Type + +Pulls a published resource type from your organization's catalog into a local +directory. + +## Usage + +```bash +mass resource-type pull [flags] +``` + +## Examples + +```bash +# Pull the latest version into a directory named after the resource type +mass resource-type pull my-resource-type + +# Pull a specific version into a specific directory +mass resource-type pull my-resource-type --version 1.2.0 --directory ./out +``` diff --git a/internal/bundle/publish.go b/internal/bundle/publish.go index 297b59c4..257497b3 100644 --- a/internal/bundle/publish.go +++ b/internal/bundle/publish.go @@ -1,107 +1,27 @@ package bundle import ( - "bytes" - "context" "fmt" "os" "path/filepath" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" ignore "github.com/sabhiram/go-gitignore" - oras "oras.land/oras-go/v2" - "oras.land/oras-go/v2/content" ) -// Publisher handles packaging and publishing bundles to an OCI registry. -type Publisher struct { - Store oras.Target - Repo oras.Target -} - -// PublishBundle copies the packaged bundle manifest from the local store to the remote repository. -func (p *Publisher) PublishBundle(ctx context.Context, tag string) error { - _, copyErr := oras.Copy(ctx, p.Store, tag, p.Repo, tag, oras.DefaultCopyOptions) - return copyErr -} +// ArtifactType is the OCI artifact-type media type for bundles. +const ArtifactType = "application/vnd.massdriver.bundle.v1+json" -// PackageBundle walks bundleDir, pushes all files to the OCI store, and creates a manifest tagged with tag. -func (p *Publisher) PackageBundle(ctx context.Context, bundleDir string, tag string) (ocispec.Descriptor, error) { +// PackageKeep returns the keep predicate used when packaging a bundle. It honors +// a bundle's optional .mdignore file, falling back to a default allowlist that +// only lets the expected bundle files through. +func PackageKeep(bundleDir string) (func(relPath string) bool, error) { ignoreMatcher, ignoreErr := getIgnores(filepath.Join(bundleDir, ".mdignore")) if ignoreErr != nil { - return ocispec.Descriptor{}, ignoreErr - } - - var layers []ocispec.Descriptor - var pushedDigests = make(map[string]string) - if walkErr := filepath.Walk(bundleDir, func(file string, fi os.FileInfo, err error) error { - if err != nil { - return err - } - if fi.IsDir() { - return nil - } - - // Calculate relative path from bundle directory - bundleRelativePath, err := filepath.Rel(bundleDir, file) - if err != nil { - return err - } - bundleRelativePath = filepath.ToSlash(bundleRelativePath) - - if ignoreMatcher != nil && ignoreMatcher.MatchesPath(bundleRelativePath) { - return nil - } - - descriptor, addErr := addFileToStore(ctx, p.Store, file, bundleRelativePath, pushedDigests) - if addErr != nil { - return addErr - } - layers = append(layers, *descriptor) - - return nil - }); walkErr != nil { - return ocispec.Descriptor{}, walkErr - } - - // 3. Pack the files and tag the packed manifest - artifactType := "application/vnd.massdriver.bundle.v1+json" - opts := oras.PackManifestOptions{ - Layers: layers, - } - manifestDescriptor, packErr := oras.PackManifest(ctx, p.Store, oras.PackManifestVersion1_1, artifactType, opts) - if packErr != nil { - return ocispec.Descriptor{}, packErr + return nil, ignoreErr } - - if tagErr := p.Store.Tag(ctx, manifestDescriptor, tag); tagErr != nil { - return ocispec.Descriptor{}, tagErr - } - - return manifestDescriptor, nil -} - -func addFileToStore(ctx context.Context, store content.Pusher, filePath string, relativePath string, pushedDigests map[string]string) (*ocispec.Descriptor, error) { - data, readErr := os.ReadFile(filePath) - if readErr != nil { - return nil, fmt.Errorf("reading %s: %w", filePath, readErr) - } - - mimeType := getMimeTypeFromExtension(filepath.Ext(filePath)) - descriptor := content.NewDescriptorFromBytes(mimeType, data) - descriptor.Annotations = map[string]string{ - ocispec.AnnotationTitle: relativePath, - } - - digest := descriptor.Digest.String() - if _, exists := pushedDigests[digest]; !exists { - pushErr := store.Push(ctx, descriptor, bytes.NewReader(data)) - if pushErr != nil { - return nil, fmt.Errorf("pushing %s: %w", filePath, pushErr) - } - pushedDigests[digest] = relativePath - } - return &descriptor, nil + return func(relPath string) bool { + return ignoreMatcher == nil || !ignoreMatcher.MatchesPath(relPath) + }, nil } // Loads patterns from .mdignore file and returns a matcher @@ -154,71 +74,3 @@ func getIgnores(ignorePath string) (*ignore.GitIgnore, error) { } return gi, nil } - -func getMimeTypeFromExtension(ext string) string { - if mimeType, exists := mimeTypesFromExt[ext]; exists { - return mimeType - } - return "" -} - -var mimeTypesFromExt = map[string]string{ - // Text formats - ".txt": "text/plain", - ".md": "text/markdown", - ".mdx": "text/markdown", - ".csv": "text/csv", - ".log": "text/plain", - // Configuration / serialization - ".json": "application/json", - ".yaml": "application/yaml", - ".yml": "application/yaml", - ".toml": "application/toml", - ".ini": "text/plain", // technically ambiguous - // HTML, XML - ".html": "text/html", - ".xml": "application/xml", - // Source code - ".go": "text/x-go", - ".py": "text/x-python", - ".js": "application/javascript", - ".ts": "application/typescript", - ".java": "text/x-java-source", - ".rb": "text/x-ruby", - ".sh": "application/x-sh", - ".bash": "application/x-sh", - ".c": "text/x-c", - ".cpp": "text/x-c++", - ".cs": "text/x-csharp", - ".php": "application/x-httpd-php", - // Infrastructure as code / DevOps - ".tf": "application/hcl", - ".tfvars": "application/hcl", - ".hcl": "application/hcl", - ".rego": "text/plain", // Open Policy Agent - ".dockerfile": "text/x-dockerfile", - // Shell scripts / dotfiles - ".env": "text/plain", - ".gitignore": "text/plain", - ".gitattributes": "text/plain", - ".bashrc": "text/x-shellscript", - // Archives - ".zip": "application/x-zip-compressed", - ".tar": "application/x-tar", - ".gz": "application/x-gzip", - ".tgz": "application/x-gzip", - ".tar.gz": "application/x-gzip", - // Binary - ".exe": "application/vnd.microsoft.portable-executable", - ".dll": "application/vnd.microsoft.portable-executable", - ".wasm": "application/wasm", - // Images (commonly used in docs/pipelines) - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".svg": "image/svg+xml", - // Certificates / keys - ".pem": "application/x-pem-file", - ".crt": "application/x-x509-ca-cert", - ".key": "application/x-pem-file", -} diff --git a/internal/bundle/publish_test.go b/internal/bundle/publish_test.go index 9ae943e7..3b25e6e9 100644 --- a/internal/bundle/publish_test.go +++ b/internal/bundle/publish_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/massdriver-cloud/mass/internal/bundle" + "github.com/massdriver-cloud/mass/internal/oci" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content/memory" ) @@ -38,14 +39,19 @@ func TestPackageBundle(t *testing.T) { t.Run(tc.name, func(t *testing.T) { memStore := memory.New() - p := bundle.Publisher{ + p := oci.Publisher{ Store: memStore, } + keep, keepErr := bundle.PackageKeep(tc.bundleDir) + if keepErr != nil { + t.Fatalf("PackageKeep failed: %v", keepErr) + } + tag := "test-tag" - desc, err := p.PackageBundle(t.Context(), tc.bundleDir, tag) + desc, err := p.Package(t.Context(), tc.bundleDir, tag, bundle.ArtifactType, keep) if err != nil { - t.Fatalf("PackageBundle failed: %v", err) + t.Fatalf("Package failed: %v", err) } // Fetch and parse the manifest diff --git a/internal/bundle/pull.go b/internal/bundle/pull.go deleted file mode 100644 index 752830f1..00000000 --- a/internal/bundle/pull.go +++ /dev/null @@ -1,19 +0,0 @@ -package bundle - -import ( - "context" - - v1 "github.com/opencontainers/image-spec/specs-go/v1" - oras "oras.land/oras-go/v2" -) - -// Puller handles pulling bundles from an OCI registry into a local target. -type Puller struct { - Target oras.Target - Repo oras.Target -} - -// PullBundle copies the bundle at the given version from the remote repository to the local target. -func (p *Puller) PullBundle(ctx context.Context, version string) (v1.Descriptor, error) { - return oras.Copy(ctx, p.Repo, version, p.Target, version, oras.DefaultCopyOptions) -} diff --git a/internal/commands/bundle/publish.go b/internal/commands/bundle/publish.go index c3e0874e..10bf5b3d 100644 --- a/internal/commands/bundle/publish.go +++ b/internal/commands/bundle/publish.go @@ -7,6 +7,7 @@ import ( "time" "github.com/massdriver-cloud/mass/internal/bundle" + "github.com/massdriver-cloud/mass/internal/oci" "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" @@ -30,14 +31,19 @@ func RunPublish(ctx context.Context, b *bundle.Bundle, mdClient *massdriver.Clie return fmt.Errorf("getting repository: %w", repoErr) } store := memory.New() - publisher := &bundle.Publisher{ + publisher := &oci.Publisher{ Store: store, Repo: repo, } fmt.Printf("Packaging bundle %s...\n", printBundleName) - manifestDescriptor, packageErr := publisher.PackageBundle(ctx, buildFromDir, version) + keep, keepErr := bundle.PackageKeep(buildFromDir) + if keepErr != nil { + return fmt.Errorf("packaging bundle: %w", keepErr) + } + + manifestDescriptor, packageErr := publisher.Package(ctx, buildFromDir, version, bundle.ArtifactType, keep) if packageErr != nil { return fmt.Errorf("packaging bundle: %w", packageErr) } @@ -45,7 +51,7 @@ func RunPublish(ctx context.Context, b *bundle.Bundle, mdClient *massdriver.Clie fmt.Printf("Package %s created with digest: %s\n", printBundleName, manifestDescriptor.Digest) fmt.Printf("Pushing %s to package manager...\n", printBundleName) - publishErr := publisher.PublishBundle(ctx, version) + publishErr := publisher.Publish(ctx, version) if publishErr != nil { return fmt.Errorf("publishing bundle: %w", publishErr) } diff --git a/internal/commands/bundle/pull.go b/internal/commands/bundle/pull.go index 045a110f..85c29025 100644 --- a/internal/commands/bundle/pull.go +++ b/internal/commands/bundle/pull.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/massdriver-cloud/mass/internal/bundle" + "github.com/massdriver-cloud/mass/internal/oci" "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "oras.land/oras-go/v2/content/file" @@ -36,12 +36,12 @@ func RunPull(ctx context.Context, mdClient *massdriver.Client, bundleName string } defer store.Close() - puller := &bundle.Puller{ + puller := &oci.Puller{ Target: store, Repo: repo, } - descriptor, pullErr := puller.PullBundle(ctx, tag) + descriptor, pullErr := puller.Pull(ctx, tag) if pullErr != nil { return fmt.Errorf("failed to pull bundle: %w", pullErr) } diff --git a/internal/commands/instance/export.go b/internal/commands/instance/export.go index efeae9c7..9b94162a 100644 --- a/internal/commands/instance/export.go +++ b/internal/commands/instance/export.go @@ -10,7 +10,7 @@ import ( "os" "path/filepath" - "github.com/massdriver-cloud/mass/internal/bundle" + "github.com/massdriver-cloud/mass/internal/oci" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" "oras.land/oras-go/v2/content/file" @@ -88,12 +88,12 @@ func (dbf *DefaultBundleFetcher) FetchBundle(ctx context.Context, bundleName, ve } defer store.Close() - puller := &bundle.Puller{ + puller := &oci.Puller{ Target: store, Repo: repo, } - _, pullErr := puller.PullBundle(ctx, version) + _, pullErr := puller.Pull(ctx, version) return pullErr } diff --git a/internal/oci/oci.go b/internal/oci/oci.go new file mode 100644 index 00000000..4e4f989a --- /dev/null +++ b/internal/oci/oci.go @@ -0,0 +1,188 @@ +// Package oci contains the raw OCI packaging, publishing, and pulling logic +// shared by bundles and resource types. Callers supply the artifact-type media +// type and a per-file keep predicate; everything else (walking the directory, +// pushing layers, packing the manifest, copying to/from the remote repo) is +// identical across artifact kinds and lives here. +package oci + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + oras "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" +) + +// Publisher packages a local directory into an OCI store and pushes it to a +// remote repository. +type Publisher struct { + Store oras.Target + Repo oras.Target +} + +// Publish copies the packaged manifest from the local store to the remote +// repository under tag. +func (p *Publisher) Publish(ctx context.Context, tag string) error { + _, copyErr := oras.Copy(ctx, p.Store, tag, p.Repo, tag, oras.DefaultCopyOptions) + return copyErr +} + +// Package walks srcDir and pushes every file for which keep returns true into +// the store, then packs and tags a manifest of the given artifactType. A nil +// keep predicate includes every file. Paths passed to keep are slash-separated +// and relative to srcDir. +func (p *Publisher) Package(ctx context.Context, srcDir, tag, artifactType string, keep func(relPath string) bool) (ocispec.Descriptor, error) { + var layers []ocispec.Descriptor + pushedDigests := make(map[string]string) + + if walkErr := filepath.Walk(srcDir, func(file string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + if fi.IsDir() { + return nil + } + + relativePath, relErr := filepath.Rel(srcDir, file) + if relErr != nil { + return relErr + } + relativePath = filepath.ToSlash(relativePath) + + if keep != nil && !keep(relativePath) { + return nil + } + + descriptor, addErr := addFileToStore(ctx, p.Store, file, relativePath, pushedDigests) + if addErr != nil { + return addErr + } + layers = append(layers, *descriptor) + + return nil + }); walkErr != nil { + return ocispec.Descriptor{}, walkErr + } + + opts := oras.PackManifestOptions{ + Layers: layers, + } + manifestDescriptor, packErr := oras.PackManifest(ctx, p.Store, oras.PackManifestVersion1_1, artifactType, opts) + if packErr != nil { + return ocispec.Descriptor{}, packErr + } + + if tagErr := p.Store.Tag(ctx, manifestDescriptor, tag); tagErr != nil { + return ocispec.Descriptor{}, tagErr + } + + return manifestDescriptor, nil +} + +// Puller copies an artifact from a remote repository into a local target. +type Puller struct { + Target oras.Target + Repo oras.Target +} + +// Pull copies the artifact at tag from the remote repository into the target. +func (p *Puller) Pull(ctx context.Context, tag string) (ocispec.Descriptor, error) { + return oras.Copy(ctx, p.Repo, tag, p.Target, tag, oras.DefaultCopyOptions) +} + +func addFileToStore(ctx context.Context, store content.Pusher, filePath, relativePath string, pushedDigests map[string]string) (*ocispec.Descriptor, error) { + data, readErr := os.ReadFile(filePath) + if readErr != nil { + return nil, fmt.Errorf("reading %s: %w", filePath, readErr) + } + + mimeType := MimeTypeFromExtension(filepath.Ext(filePath)) + descriptor := content.NewDescriptorFromBytes(mimeType, data) + descriptor.Annotations = map[string]string{ + ocispec.AnnotationTitle: relativePath, + } + + digest := descriptor.Digest.String() + if _, exists := pushedDigests[digest]; !exists { + pushErr := store.Push(ctx, descriptor, bytes.NewReader(data)) + if pushErr != nil { + return nil, fmt.Errorf("pushing %s: %w", filePath, pushErr) + } + pushedDigests[digest] = relativePath + } + return &descriptor, nil +} + +// MimeTypeFromExtension returns the media type for a file extension (including +// the leading dot), or the empty string when unknown. +func MimeTypeFromExtension(ext string) string { + if mimeType, exists := mimeTypesFromExt[ext]; exists { + return mimeType + } + return "" +} + +var mimeTypesFromExt = map[string]string{ + // Text formats + ".txt": "text/plain", + ".md": "text/markdown", + ".mdx": "text/markdown", + ".csv": "text/csv", + ".log": "text/plain", + // Configuration / serialization + ".json": "application/json", + ".yaml": "application/yaml", + ".yml": "application/yaml", + ".toml": "application/toml", + ".ini": "text/plain", // technically ambiguous + // HTML, XML + ".html": "text/html", + ".xml": "application/xml", + // Source code + ".go": "text/x-go", + ".py": "text/x-python", + ".js": "application/javascript", + ".ts": "application/typescript", + ".java": "text/x-java-source", + ".rb": "text/x-ruby", + ".sh": "application/x-sh", + ".bash": "application/x-sh", + ".c": "text/x-c", + ".cpp": "text/x-c++", + ".cs": "text/x-csharp", + ".php": "application/x-httpd-php", + // Infrastructure as code / DevOps + ".tf": "application/hcl", + ".tfvars": "application/hcl", + ".hcl": "application/hcl", + ".rego": "text/plain", // Open Policy Agent + ".dockerfile": "text/x-dockerfile", + // Shell scripts / dotfiles + ".env": "text/plain", + ".gitignore": "text/plain", + ".gitattributes": "text/plain", + ".bashrc": "text/x-shellscript", + // Archives + ".zip": "application/x-zip-compressed", + ".tar": "application/x-tar", + ".gz": "application/x-gzip", + ".tgz": "application/x-gzip", + ".tar.gz": "application/x-gzip", + // Binary + ".exe": "application/vnd.microsoft.portable-executable", + ".dll": "application/vnd.microsoft.portable-executable", + ".wasm": "application/wasm", + // Images (commonly used in docs/pipelines) + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", + // Certificates / keys + ".pem": "application/x-pem-file", + ".crt": "application/x-x509-ca-cert", + ".key": "application/x-pem-file", +} diff --git a/internal/bundle/pull_test.go b/internal/oci/pull_test.go similarity index 94% rename from internal/bundle/pull_test.go rename to internal/oci/pull_test.go index f9e0908c..337515b6 100644 --- a/internal/bundle/pull_test.go +++ b/internal/oci/pull_test.go @@ -1,11 +1,11 @@ -package bundle_test +package oci_test import ( "bytes" "encoding/json" "testing" - "github.com/massdriver-cloud/mass/internal/bundle" + "github.com/massdriver-cloud/mass/internal/oci" ocispec "github.com/opencontainers/image-spec/specs-go/v1" oras "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" @@ -77,11 +77,11 @@ func TestPull(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - puller := &bundle.Puller{ + puller := &oci.Puller{ Target: tc.target, Repo: tc.repo, } - desc, pullErr := puller.PullBundle(t.Context(), tc.tag) + desc, pullErr := puller.Pull(t.Context(), tc.tag) if (pullErr != nil) != tc.wantErr { t.Fatalf("unexpected error = %v, wantErr %v", pullErr, tc.wantErr) } diff --git a/internal/resourcetype/build.go b/internal/resourcetype/build.go index dc8a1643..ffd29432 100644 --- a/internal/resourcetype/build.go +++ b/internal/resourcetype/build.go @@ -13,18 +13,19 @@ import ( // This is an experimental format that provides a more ergonomic authoring experience. type MassdriverYAML struct { Name string `yaml:"name"` - Label string `yaml:"label"` - Icon string `yaml:"icon"` - UI *UIConfig `yaml:"ui"` - Exports []ExportConfig `yaml:"exports"` + Version string `yaml:"version,omitempty"` + Label string `yaml:"label,omitempty"` + Icon string `yaml:"icon,omitempty"` + UI *UIConfig `yaml:"ui,omitempty"` + Exports []ExportConfig `yaml:"exports,omitempty"` Schema map[string]any `yaml:"schema"` } // UIConfig represents the UI configuration section type UIConfig struct { - ConnectionOrientation string `yaml:"connectionOrientation"` - EnvironmentDefaultGroup string `yaml:"environmentDefaultGroup"` - Instructions []InstructionConfig `yaml:"instructions"` + ConnectionOrientation string `yaml:"connectionOrientation,omitempty"` + EnvironmentDefaultGroup string `yaml:"environmentDefaultGroup,omitempty"` + Instructions []InstructionConfig `yaml:"instructions,omitempty"` } // InstructionConfig represents an instruction file reference @@ -41,9 +42,9 @@ type ExportConfig struct { TemplateLang string `yaml:"templateLang"` } -// Build reads a massdriver.yaml file and builds it into the resource type -// format expected by the Massdriver API. -func Build(path string) (map[string]any, error) { +// ReadConfig reads and parses a massdriver.yaml resource type file into its +// structured form without dereferencing or building the schema. +func ReadConfig(path string) (*MassdriverYAML, error) { content, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read massdriver.yaml: %w", err) @@ -54,6 +55,17 @@ func Build(path string) (map[string]any, error) { return nil, fmt.Errorf("failed to parse massdriver.yaml: %w", err) } + return &config, nil +} + +// Build reads a massdriver.yaml file and builds it into the resource type +// format expected by the Massdriver API. +func Build(path string) (map[string]any, error) { + config, err := ReadConfig(path) + if err != nil { + return nil, err + } + baseDir := filepath.Dir(path) // Build the $md block diff --git a/internal/resourcetype/convert.go b/internal/resourcetype/convert.go new file mode 100644 index 00000000..b91c7327 --- /dev/null +++ b/internal/resourcetype/convert.go @@ -0,0 +1,219 @@ +package resourcetype + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// placeholderVersion is written into the converted massdriver.yaml since a raw +// JSON schema carries no version. The author must set a real version before +// publishing. +const placeholderVersion = "0.0.0" + +// ConvertResult describes the files a Convert call produced. +type ConvertResult struct { + MassdriverYAML string // path to the written massdriver.yaml + ExtraFiles []string // paths to extracted instruction/export files +} + +// Convert reads a raw JSON (or YAML) resource type schema at schemaPath and +// writes an equivalent massdriver.yaml. Inlined instruction/export content is +// extracted back out to referenced files. outputPath is the massdriver.yaml to +// write; when empty it defaults to a massdriver.yaml alongside schemaPath. +// Existing files are not overwritten unless force is set. +func Convert(schemaPath, outputPath string, force bool) (*ConvertResult, error) { + raw, readErr := readRawSchema(schemaPath) + if readErr != nil { + return nil, readErr + } + + if outputPath == "" { + outputPath = filepath.Join(filepath.Dir(schemaPath), "massdriver.yaml") + } + outputDir := filepath.Dir(outputPath) + + config, extraFiles := reverseBuild(raw) + + out, marshalErr := yaml.Marshal(config) + if marshalErr != nil { + return nil, fmt.Errorf("failed to marshal massdriver.yaml: %w", marshalErr) + } + + // Refuse to clobber anything unless forced. + targets := []string{outputPath} + for rel := range extraFiles { + targets = append(targets, filepath.Join(outputDir, rel)) + } + if !force { + for _, t := range targets { + if _, statErr := os.Stat(t); statErr == nil { + return nil, fmt.Errorf("%s already exists; use --force to overwrite", t) + } + } + } + + if mkErr := os.MkdirAll(outputDir, 0750); mkErr != nil { + return nil, fmt.Errorf("failed to create output directory: %w", mkErr) + } + + result := &ConvertResult{MassdriverYAML: outputPath} + for rel, content := range extraFiles { + dst := filepath.Join(outputDir, rel) + if mkErr := os.MkdirAll(filepath.Dir(dst), 0750); mkErr != nil { + return nil, fmt.Errorf("failed to create directory for %s: %w", rel, mkErr) + } + if writeErr := os.WriteFile(dst, content, 0600); writeErr != nil { + return nil, fmt.Errorf("failed to write %s: %w", rel, writeErr) + } + result.ExtraFiles = append(result.ExtraFiles, dst) + } + + if writeErr := os.WriteFile(outputPath, out, 0600); writeErr != nil { + return nil, fmt.Errorf("failed to write %s: %w", outputPath, writeErr) + } + + return result, nil +} + +func readRawSchema(path string) (map[string]any, error) { + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil, fmt.Errorf("failed to read schema: %w", readErr) + } + + var raw map[string]any + switch strings.ToLower(filepath.Ext(path)) { + case ".json": + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse JSON schema: %w", err) + } + case ".yaml", ".yml": + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse YAML schema: %w", err) + } + default: + return nil, fmt.Errorf("unsupported schema file extension: %s (expected .json, .yaml, or .yml)", filepath.Ext(path)) + } + return raw, nil +} + +// reverseBuild is the inverse of [Build]: it lifts the `$md` block back into the +// massdriver.yaml fields, extracts inlined instruction/export content into files +// keyed by their relative path, and moves the remaining keys under `schema`. +func reverseBuild(raw map[string]any) (*MassdriverYAML, map[string][]byte) { + config := &MassdriverYAML{Version: placeholderVersion} + extraFiles := map[string][]byte{} + + if md, ok := raw["$md"].(map[string]any); ok { + config.Name = asString(md["name"]) + config.Label = asString(md["label"]) + config.Icon = asString(md["icon"]) + + if uiRaw, ok := md["ui"].(map[string]any); ok { + config.UI = reverseUI(uiRaw, extraFiles) + } + + if exportsRaw, ok := md["export"].([]any); ok { + config.Exports = reverseExports(exportsRaw, extraFiles) + } + } + + // Everything that isn't the $md block is the JSON schema itself. + schema := map[string]any{} + for key, value := range raw { + if key == "$md" { + continue + } + schema[key] = value + } + config.Schema = schema + + return config, extraFiles +} + +func reverseUI(uiRaw map[string]any, extraFiles map[string][]byte) *UIConfig { + ui := &UIConfig{ + ConnectionOrientation: asString(uiRaw["connectionOrientation"]), + EnvironmentDefaultGroup: asString(uiRaw["environmentDefaultGroup"]), + } + + instructions, ok := uiRaw["instructions"].([]any) + if !ok { + return ui + } + for i, instRaw := range instructions { + inst, ok := instRaw.(map[string]any) + if !ok { + continue + } + label := asString(inst["label"]) + rel := uniqueRel(extraFiles, "instructions", slugify(label, i), "md", i) + extraFiles[rel] = []byte(asString(inst["content"])) + ui.Instructions = append(ui.Instructions, InstructionConfig{ + Label: label, + Path: "./" + rel, + }) + } + return ui +} + +func reverseExports(exportsRaw []any, extraFiles map[string][]byte) []ExportConfig { + var exports []ExportConfig + for i, expRaw := range exportsRaw { + exp, ok := expRaw.(map[string]any) + if !ok { + continue + } + lang := asString(exp["templateLang"]) + ext := lang + if ext == "" { + ext = "tmpl" + } + rel := uniqueRel(extraFiles, "exports", slugify(asString(exp["downloadButtonText"]), i), ext, i) + extraFiles[rel] = []byte(asString(exp["template"])) + exports = append(exports, ExportConfig{ + DownloadButtonText: asString(exp["downloadButtonText"]), + FileFormat: asString(exp["fileFormat"]), + TemplatePath: "./" + rel, + TemplateLang: lang, + }) + } + return exports +} + +// uniqueRel builds "/.", appending the item index if that path +// was already taken so two items with the same label don't clobber each other. +func uniqueRel(extraFiles map[string][]byte, dir, slug, ext string, index int) string { + rel := fmt.Sprintf("%s/%s.%s", dir, slug, ext) + if _, taken := extraFiles[rel]; !taken { + return rel + } + return fmt.Sprintf("%s/%s-%d.%s", dir, slug, index+1, ext) +} + +func asString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`) + +// slugify turns a human label into a filesystem-friendly slug, falling back to +// an index-based name when the label has no usable characters. +func slugify(label string, index int) string { + slug := nonSlugChars.ReplaceAllString(strings.ToLower(label), "-") + slug = strings.Trim(slug, "-") + if slug == "" { + return strconv.Itoa(index + 1) + } + return slug +} diff --git a/internal/resourcetype/convert_test.go b/internal/resourcetype/convert_test.go new file mode 100644 index 00000000..4baf2643 --- /dev/null +++ b/internal/resourcetype/convert_test.go @@ -0,0 +1,100 @@ +package resourcetype_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/massdriver-cloud/mass/internal/resourcetype" + "gopkg.in/yaml.v3" +) + +func TestConvert(t *testing.T) { + out := filepath.Join(t.TempDir(), "massdriver.yaml") + + result, err := resourcetype.Convert("testdata/simple-resource.json", out, false) + if err != nil { + t.Fatalf("Convert failed: %v", err) + } + if result.MassdriverYAML != out { + t.Errorf("MassdriverYAML = %q, want %q", result.MassdriverYAML, out) + } + + data, readErr := os.ReadFile(out) + if readErr != nil { + t.Fatalf("reading output: %v", readErr) + } + + var config resourcetype.MassdriverYAML + if unmarshalErr := yaml.Unmarshal(data, &config); unmarshalErr != nil { + t.Fatalf("output is not valid massdriver.yaml: %v", unmarshalErr) + } + + if config.Name != "foo" { + t.Errorf("name = %q, want %q", config.Name, "foo") + } + if config.Version == "" { + t.Error("expected a placeholder version to be written") + } + // The $md block must be lifted out of the schema. + if _, ok := config.Schema["$md"]; ok { + t.Error("schema should not contain the $md block after conversion") + } + if _, ok := config.Schema["properties"]; !ok { + t.Error("schema should retain the original JSON schema keys (properties)") + } +} + +func TestConvertDistinctFilesForDuplicateLabels(t *testing.T) { + dir := t.TempDir() + raw := `{ + "$md": { + "name": "dup", + "ui": { "instructions": [ + { "label": "Setup", "content": "first" }, + { "label": "Setup", "content": "second" } + ] } + }, + "type": "object" +}` + schemaPath := filepath.Join(dir, "raw.json") + if err := os.WriteFile(schemaPath, []byte(raw), 0600); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "out", "massdriver.yaml") + + result, err := resourcetype.Convert(schemaPath, out, false) + if err != nil { + t.Fatalf("Convert failed: %v", err) + } + if len(result.ExtraFiles) != 2 { + t.Fatalf("expected 2 distinct instruction files, got %d: %v", len(result.ExtraFiles), result.ExtraFiles) + } + + contents := map[string]bool{} + for _, f := range result.ExtraFiles { + data, readErr := os.ReadFile(f) + if readErr != nil { + t.Fatal(readErr) + } + contents[string(data)] = true + } + if !contents["first"] || !contents["second"] { + t.Errorf("both instruction contents should be preserved, got: %v", contents) + } +} + +func TestConvertRefusesToClobber(t *testing.T) { + out := filepath.Join(t.TempDir(), "massdriver.yaml") + if err := os.WriteFile(out, []byte("existing"), 0600); err != nil { + t.Fatal(err) + } + + if _, err := resourcetype.Convert("testdata/simple-resource.json", out, false); err == nil { + t.Fatal("expected an error when the output file already exists") + } + + if _, err := resourcetype.Convert("testdata/simple-resource.json", out, true); err != nil { + t.Fatalf("expected --force to overwrite, got: %v", err) + } +} diff --git a/internal/resourcetype/delete.go b/internal/resourcetype/delete.go index 1746124e..437acab3 100644 --- a/internal/resourcetype/delete.go +++ b/internal/resourcetype/delete.go @@ -2,13 +2,25 @@ package resourcetype import ( "context" + "fmt" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/ocirepos" ) -// Delete removes a resource type by name. UX (confirmation prompt, success -// message) is the caller's responsibility — see [cmd.runTypeDelete]. -func Delete(ctx context.Context, mdClient *massdriver.Client, name string) (*ResourceType, error) { - return api.DeleteResourceType(ctx, mdClient, name) +// Delete removes a resource type's OCI repository. Because published versions +// are immutable, deletion is refused locally when the repository already has +// tags. UX (confirmation prompt, success message) is the caller's +// responsibility — see [cmd.runTypeDelete]. +func Delete(ctx context.Context, mdClient *massdriver.Client, name string) (*ocirepos.OciRepo, error) { + repo, getErr := mdClient.OciRepos.Get(ctx, name) + if getErr != nil { + return nil, fmt.Errorf("fetching OCI repo: %w", getErr) + } + + if len(repo.Tags) > 0 { + return nil, fmt.Errorf("resource type %s has published versions and is immutable; its repository cannot be deleted", name) + } + + return mdClient.OciRepos.Delete(ctx, name) } diff --git a/internal/resourcetype/delete_test.go b/internal/resourcetype/delete_test.go deleted file mode 100644 index 0c761344..00000000 --- a/internal/resourcetype/delete_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package resourcetype_test - -import ( - "strings" - "testing" - - "github.com/massdriver-cloud/mass/internal/api" - "github.com/massdriver-cloud/mass/internal/resourcetype" - - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql/gqltest" -) - -func TestDelete(t *testing.T) { - type test struct { - name string - typeName string - response map[string]any - expectErr bool - errMessage string - } - tests := []test{ - { - name: "simple", - typeName: "aws-s3", - response: map[string]any{ - "id": "123-456", - "name": "massdriver/test-schema", - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - mock := gqltest.NewClient( - gqltest.RespondWithData(map[string]any{ - "deleteResourceType": map[string]any{ - "result": tc.response, - "successful": true, - }, - }), - ) - t.Cleanup(api.SetTransportForTest(mock)) - mdClient, err := massdriver.NewClient( - massdriver.WithGQLClient(mock), - massdriver.WithOrganizationID("org-123"), - ) - if err != nil { - t.Fatal(err) - } - - deleted, err := resourcetype.Delete(t.Context(), mdClient, tc.typeName) - if tc.expectErr { - if err == nil { - t.Fatalf("expected error but got none") - } - if !strings.Contains(err.Error(), tc.errMessage) { - t.Fatalf("expected error message to contain %q but got %q", tc.errMessage, err.Error()) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if deleted == nil || deleted.Name != tc.response["name"] { - t.Fatalf("expected deleted record with name %v, got %v", tc.response["name"], deleted) - } - }) - } -} diff --git a/internal/resourcetype/keep_test.go b/internal/resourcetype/keep_test.go new file mode 100644 index 00000000..d62d11ff --- /dev/null +++ b/internal/resourcetype/keep_test.go @@ -0,0 +1,39 @@ +package resourcetype //nolint:testpackage // needs access to unexported packageKeep + +import "testing" + +func TestPackageKeep(t *testing.T) { + keep := []string{ + "massdriver.yaml", + "README.md", + "readme.md", + "CHANGELOG.md", + "icon.svg", + "icon.png", + "icon.jpg", + "icon.jpeg", + "instructions/cli.md", + "instructions/nested/deep.md", + "exports/config.yaml.liquid", + } + drop := []string{ + "main.tf", + "schema-params.json", + "icon.gif", + ".mdignore", + "instructions", // the bare name, not a file under the dir + "docs/readme.md", + "secrets/key.pem", + } + + for _, f := range keep { + if !packageKeep(f) { + t.Errorf("packageKeep(%q) = false, want true", f) + } + } + for _, f := range drop { + if packageKeep(f) { + t.Errorf("packageKeep(%q) = true, want false", f) + } + } +} diff --git a/internal/resourcetype/publish.go b/internal/resourcetype/publish.go index 40aa3d48..a03af966 100644 --- a/internal/resourcetype/publish.go +++ b/internal/resourcetype/publish.go @@ -4,40 +4,173 @@ import ( "context" "fmt" "net/url" + "os" + "path/filepath" + "strings" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/jsonschema" + "github.com/massdriver-cloud/mass/internal/oci" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "oras.land/oras-go/v2/content/memory" ) -// Publish reads, validates, and publishes a resource type from path to the Massdriver API. -func Publish(ctx context.Context, mdClient *massdriver.Client, path string) (*ResourceType, error) { - rt, readErr := Read(ctx, mdClient, path) +// ArtifactType is the OCI artifact-type media type for resource types. +const ArtifactType = "application/vnd.massdriver.resource-type.v1+json" + +// allowedFiles is the subset of top-level files (matched case-insensitively by +// name) that may be packaged into a resource type artifact. Everything else at +// the top level is silently skipped. +var allowedFiles = map[string]bool{ + "massdriver.yaml": true, + "readme.md": true, + "changelog.md": true, + "icon.svg": true, + "icon.png": true, + "icon.jpg": true, + "icon.jpeg": true, +} + +// allowedDirs are the subdirectories a massdriver.yaml may reference (UI +// instructions and export templates); their contents are packaged too. +var allowedDirs = []string{"instructions/", "exports/"} + +// packageKeep is the keep predicate used when packaging a resource type. It +// admits the allowlisted top-level files plus anything under the referenced +// instruction/export directories. +func packageKeep(relPath string) bool { + for _, dir := range allowedDirs { + if strings.HasPrefix(relPath, dir) { + return true + } + } + if strings.Contains(relPath, "/") { + return false + } + return allowedFiles[strings.ToLower(relPath)] +} + +// Publish validates a resource type located at path and pushes it to its OCI +// repository. path may be a directory containing a massdriver.yaml, or the +// massdriver.yaml itself. It returns the resource type name and the published +// version. +func Publish(ctx context.Context, mdClient *massdriver.Client, path string) (string, string, error) { + mdYamlPath, srcDir, resolveErr := resolvePublishPath(path) + if resolveErr != nil { + return "", "", resolveErr + } + + config, configErr := ReadConfig(mdYamlPath) + if configErr != nil { + return "", "", fmt.Errorf("failed to read massdriver.yaml: %w", configErr) + } + if config.Name == "" { + return "", "", fmt.Errorf("name is required in %s", mdYamlPath) + } + if config.Version == "" { + return "", "", fmt.Errorf("version is required in %s", mdYamlPath) + } + + // Fail fast on a duplicate version before the network-heavy schema + // dereference and validation. + if versionErr := checkDuplicateVersion(ctx, mdClient, config.Name, config.Version); versionErr != nil { + return "", "", versionErr + } + + if validateErr := validateSchema(ctx, mdClient, mdYamlPath); validateErr != nil { + return "", "", validateErr + } + + repo, repoErr := mdClient.OciRepos.Target(config.Name) + if repoErr != nil { + return "", "", fmt.Errorf("getting repository: %w", repoErr) + } + + publisher := &oci.Publisher{ + Store: memory.New(), + Repo: repo, + } + + if _, packageErr := publisher.Package(ctx, srcDir, config.Version, ArtifactType, packageKeep); packageErr != nil { + return "", "", fmt.Errorf("packaging resource type: %w", packageErr) + } + + if publishErr := publisher.Publish(ctx, config.Version); publishErr != nil { + return "", "", fmt.Errorf("publishing resource type: %w", publishErr) + } + + return config.Name, config.Version, nil +} + +// resolvePublishPath resolves the publish target into the massdriver.yaml path +// and its containing directory, rejecting raw JSON schema files with a pointer +// to the convert command. +func resolvePublishPath(path string) (mdYamlPath string, srcDir string, err error) { + info, statErr := os.Stat(path) + if statErr != nil { + return "", "", fmt.Errorf("failed to read resource type path: %w", statErr) + } + + if info.IsDir() { + md := filepath.Join(path, "massdriver.yaml") + if _, mdErr := os.Stat(md); mdErr != nil { + return "", "", fmt.Errorf("no massdriver.yaml found in %s", path) + } + return md, path, nil + } + + if filepath.Base(path) == "massdriver.yaml" { + return path, filepath.Dir(path), nil + } + + switch strings.ToLower(filepath.Ext(path)) { + case ".json", ".yaml", ".yml": + return "", "", fmt.Errorf("publishing a raw JSON schema is no longer supported; run `mass resource-type convert %s` to migrate it to a massdriver.yaml", path) + default: + return "", "", fmt.Errorf("unsupported resource type path: %s (expected a directory or massdriver.yaml)", path) + } +} + +// validateSchema builds and dereferences the resource type, then validates it +// against the resource type schema and the JSON Schema meta-schema. +func validateSchema(ctx context.Context, mdClient *massdriver.Client, mdYamlPath string) error { + rt, readErr := Read(ctx, mdClient, mdYamlPath) if readErr != nil { - return nil, fmt.Errorf("failed to read resource type: %w", readErr) + return fmt.Errorf("failed to read resource type: %w", readErr) } - // validate resource type against JSON Schema meta-schema - // and resource type schema cfg := mdClient.Config() rtSchemaURL, err := url.JoinPath(cfg.URL, "json-schemas", "resource-type.json") if err != nil { - return nil, fmt.Errorf("failed to construct resource type schema URL: %w", err) + return fmt.Errorf("failed to construct resource type schema URL: %w", err) } - err = validateResourceType(rt, rtSchemaURL) - if err != nil { - return nil, fmt.Errorf("failed to validate resource type schema: %w", err) + if validateErr := validateResourceType(rt, rtSchemaURL); validateErr != nil { + return fmt.Errorf("failed to validate resource type schema: %w", validateErr) } + metaSchemaURL, err := url.JoinPath(cfg.URL, "json-schemas", "draft-7.json") if err != nil { - return nil, fmt.Errorf("failed to construct meta schema URL: %w", err) + return fmt.Errorf("failed to construct meta schema URL: %w", err) } - err = validateResourceType(rt, metaSchemaURL) - if err != nil { - return nil, fmt.Errorf("failed to validate resource type against meta schema: %w", err) + if validateErr := validateResourceType(rt, metaSchemaURL); validateErr != nil { + return fmt.Errorf("failed to validate resource type against meta schema: %w", validateErr) } - return api.PublishResourceType(ctx, mdClient, api.PublishResourceTypeInput{Schema: rt}) + return nil +} + +// checkDuplicateVersion fails locally if version has already been published, +// matching the immutability the API enforces. +func checkDuplicateVersion(ctx context.Context, mdClient *massdriver.Client, name, version string) error { + repo, err := mdClient.OciRepos.Get(ctx, name) + if err != nil { + return fmt.Errorf("fetching OCI repo: %w", err) + } + for _, t := range repo.Tags { + if t.Tag == version { + return fmt.Errorf("version %s already exists for resource type %s", version, name) + } + } + return nil } func validateResourceType(rt map[string]any, schemaURL string) error { diff --git a/internal/resourcetype/publish_test.go b/internal/resourcetype/publish_test.go index 3d52f529..a9ac7efc 100644 --- a/internal/resourcetype/publish_test.go +++ b/internal/resourcetype/publish_test.go @@ -1,88 +1,54 @@ package resourcetype_test import ( - "net/http" - "net/http/httptest" "os" + "path/filepath" + "strings" "testing" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/resourcetype" - - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql/gqltest" ) -func TestPublish(t *testing.T) { - type test struct { - name string - path string +// TestPublishValidation covers the local validation Publish performs before it +// touches the OCI registry: rejecting raw schema files (pointing at convert) +// and requiring name/version in the massdriver.yaml. These paths short-circuit +// before the massdriver client is used, so a nil client is fine. +func TestPublishValidation(t *testing.T) { + dir := t.TempDir() + + rawJSON := filepath.Join(dir, "schema.json") + if err := os.WriteFile(rawJSON, []byte("{}"), 0600); err != nil { + t.Fatal(err) + } + noVersionDir := t.TempDir() + if err := os.WriteFile(filepath.Join(noVersionDir, "massdriver.yaml"), []byte("name: foo\n"), 0600); err != nil { + t.Fatal(err) } - tests := []test{ - { - name: "simple json", - path: "testdata/simple-resource.json", - }, - { - name: "massdriver.yaml format", - path: "testdata/massdriver-yaml-simple/massdriver.yaml", - }, - { - name: "massdriver.yaml with instructions and exports", - path: "testdata/massdriver-yaml-resource/massdriver.yaml", - }, + noNameDir := t.TempDir() + if err := os.WriteFile(filepath.Join(noNameDir, "massdriver.yaml"), []byte("version: 1.0.0\n"), 0600); err != nil { + t.Fatal(err) + } + emptyDir := t.TempDir() + + tests := []struct { + name string + path string + contains string + }{ + {name: "raw JSON schema rejected", path: rawJSON, contains: "convert"}, + {name: "directory without massdriver.yaml", path: emptyDir, contains: "no massdriver.yaml"}, + {name: "missing version", path: noVersionDir, contains: "version is required"}, + {name: "missing name", path: noNameDir, contains: "name is required"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - resourceTypeSchema, err := os.ReadFile("testdata/resourcetype-schema.json") - if err != nil { - t.Fatalf("failed to read resource type schema: %v", err) - } - metaSchema, err := os.ReadFile("testdata/draft-7.json") - if err != nil { - t.Fatalf("failed to read meta schema: %v", err) + _, _, err := resourcetype.Publish(t.Context(), nil, tc.path) + if err == nil { + t.Fatalf("expected an error, got nil") } - - // Start mock HTTP server (serves the meta-schema and the resource-type - // JSON Schema that Publish() validates the input against). - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/json-schemas/resource-type.json": - _, _ = w.Write(resourceTypeSchema) - case "/json-schemas/draft-7.json": - _, _ = w.Write(metaSchema) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - mock := gqltest.NewClient( - gqltest.RespondWithData(map[string]any{ - "publishResourceType": map[string]any{ - "result": map[string]any{ - "id": "123-456", - "name": "massdriver/test-schema", - }, - "successful": true, - }, - }), - ) - t.Cleanup(api.SetTransportForTest(mock)) - - mdClient, err := massdriver.NewClient( - massdriver.WithGQLClient(mock), - massdriver.WithBaseURL(server.URL), - massdriver.WithOrganizationID("test-org"), - ) - if err != nil { - t.Fatal(err) - } - - _, err = resourcetype.Publish(t.Context(), mdClient, tc.path) - if err != nil { - t.Fatalf("%v, unexpected error", err) + if !strings.Contains(err.Error(), tc.contains) { + t.Fatalf("expected error to contain %q, got: %v", tc.contains, err) } }) } diff --git a/internal/resourcetype/pull.go b/internal/resourcetype/pull.go new file mode 100644 index 00000000..4c86eb13 --- /dev/null +++ b/internal/resourcetype/pull.go @@ -0,0 +1,78 @@ +package resourcetype + +import ( + "context" + "fmt" + + "github.com/massdriver-cloud/mass/internal/oci" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "oras.land/oras-go/v2/content/file" +) + +// Pull downloads a resource type from its OCI repository into directory, +// resolving version to a concrete tag. It returns the resolved tag and the +// pulled manifest digest. +func Pull(ctx context.Context, mdClient *massdriver.Client, name, version, directory string) (string, string, error) { + repo, repoErr := mdClient.OciRepos.Target(name) + if repoErr != nil { + return "", "", repoErr + } + + tag, tagErr := resolveTag(ctx, mdClient, name, version) + if tagErr != nil { + return "", "", tagErr + } + + store, fileErr := file.New(directory) + if fileErr != nil { + return "", "", fmt.Errorf("failed to create file store: %w", fileErr) + } + defer store.Close() + + puller := &oci.Puller{ + Target: store, + Repo: repo, + } + + descriptor, pullErr := puller.Pull(ctx, tag) + if pullErr != nil { + return "", "", fmt.Errorf("failed to pull resource type: %w", pullErr) + } + + return tag, descriptor.Digest.String(), nil +} + +// resolveTag maps a user-supplied version (a concrete tag, a release channel +// name, or "latest") to a concrete OCI tag. +func resolveTag(ctx context.Context, mdClient *massdriver.Client, name, version string) (string, error) { + repo, getErr := mdClient.OciRepos.Get(ctx, name) + if getErr != nil { + return "", fmt.Errorf("failed to get OCI repo: %w", getErr) + } + + if version == "" || version == "latest" { + // Prefer the "latest" release channel; otherwise fall back to the newest + // tag (the Get query returns tags sorted by version, descending). + if repo.LatestTag != "" { + return repo.LatestTag, nil + } + if len(repo.Tags) > 0 { + return repo.Tags[0].Tag, nil + } + return "", fmt.Errorf("no published versions found for resource type '%s'", name) + } + + for _, t := range repo.Tags { + if t.Tag == version { + return version, nil + } + } + + for _, channel := range repo.ReleaseChannels { + if version == channel.Name { + return channel.Tag, nil + } + } + + return "", fmt.Errorf("version or release channel '%s' not found for resource type '%s'", version, name) +} From 444ed35aa87ad7bb4aecc4be385b96cf8dcd8278 Mon Sep 17 00:00:00 2001 From: chrisghill Date: Fri, 7 Aug 2026 13:45:01 -0600 Subject: [PATCH 02/19] remove api package, use SDK for all resource type interactions --- cmd/resource_type.go | 25 ++-- go.mod | 4 +- go.sum | 4 +- internal/api/api.go | 71 --------- internal/api/resource_type.go | 241 ------------------------------ internal/resourcetype/get.go | 48 ++++-- internal/resourcetype/get_test.go | 160 +++++++------------- 7 files changed, 103 insertions(+), 450 deletions(-) delete mode 100644 internal/api/api.go delete mode 100644 internal/api/resource_type.go diff --git a/cmd/resource_type.go b/cmd/resource_type.go index 36d537f1..e6f247bd 100644 --- a/cmd/resource_type.go +++ b/cmd/resource_type.go @@ -20,6 +20,7 @@ import ( "github.com/massdriver-cloud/mass/internal/resourcetype" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/ocirepos" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" "github.com/spf13/cobra" ) @@ -263,24 +264,28 @@ func runTypeList(cmd *cobra.Command, args []string) error { return fmt.Errorf("error initializing massdriver client: %w", err) } - resourceTypes, err := resourcetype.List(ctx, mdClient) - if err != nil { - return err - } + seq := mdClient.OciRepos.Iter(ctx, ocirepos.ListInput{ + ArtifactType: ocirepos.ArtifactTypeResourceType, + }) switch output { case "json": - jsonBytes, marshalErr := json.MarshalIndent(resourceTypes, "", " ") + repos, collectErr := types.Collect(seq) + if collectErr != nil { + return fmt.Errorf("failed to list resource types: %w", collectErr) + } + jsonBytes, marshalErr := json.MarshalIndent(repos, "", " ") if marshalErr != nil { return fmt.Errorf("failed to marshal resource types to JSON: %w", marshalErr) } fmt.Println(string(jsonBytes)) case "table": - tbl := cli.NewTable("ID", "Name", "Updated At") - for _, rt := range resourceTypes { - tbl.AddRow(rt.ID, rt.Name, rt.UpdatedAt) - } - tbl.Print() + return cli.Paginate(seq, cli.PagerConfig[ocirepos.OciRepo]{ + Columns: []string{"Name", "Latest", "Created At"}, + Row: func(repo ocirepos.OciRepo) []string { + return []string{repo.Name, repo.LatestTag, repo.CreatedAt.Format("2006-01-02 15:04:05")} + }, + }) default: return fmt.Errorf("unsupported output format: %s", output) } diff --git a/go.mod b/go.mod index ca1e1782..e78f1cb7 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.25.0 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/BurntSushi/toml v1.5.0 - github.com/Khan/genqlient v0.8.1 github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.2.3 github.com/charmbracelet/glamour v1.0.0 @@ -15,7 +14,7 @@ require ( github.com/itchyny/gojq v0.12.16 github.com/manifoldco/promptui v0.9.0 github.com/massdriver-cloud/airlock v0.0.10 - github.com/massdriver-cloud/massdriver-sdk-go v0.2.15 + github.com/massdriver-cloud/massdriver-sdk-go v0.2.18 github.com/mattn/go-runewidth v0.0.24 github.com/opencontainers/image-spec v1.1.1 github.com/osteele/liquid v1.7.0 @@ -36,6 +35,7 @@ require ( require ( github.com/Checkmarx/kics/v2 v2.1.20 // indirect + github.com/Khan/genqlient v0.8.1 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/alecthomas/chroma/v2 v2.26.1 // indirect diff --git a/go.sum b/go.sum index eec6467f..f803c10e 100644 --- a/go.sum +++ b/go.sum @@ -141,8 +141,8 @@ github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYt github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/massdriver-cloud/airlock v0.0.10 h1:05wz7kovH09X1VMfHcjLWylYanoLFcxuD0oZi13WG9U= github.com/massdriver-cloud/airlock v0.0.10/go.mod h1:igJm33JvINiUtbyEspUeKUWyWewG+jYyxO1UDHqLp9Q= -github.com/massdriver-cloud/massdriver-sdk-go v0.2.15 h1:ZJjirglHljaZqrHu8/3HeNK7RkMBHL0ZJTfPtgYlQlg= -github.com/massdriver-cloud/massdriver-sdk-go v0.2.15/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.18 h1:Z3j9qjZU2nYuEQ24LRuYLQoPxydVP6NzW6iy/T23xEg= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.18/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= github.com/massdriver-cloud/terraform-config-inspect v0.0.2 h1:Jc7BrhFHLbK7Epig6ShiEVMzQPPHVIOx0/BatvtEwtY= github.com/massdriver-cloud/terraform-config-inspect v0.0.2/go.mod h1:3AbDpWxIRMdMAg7FDmTJuVBhCGNwdm49cBIOmUHjqRg= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= diff --git a/internal/api/api.go b/internal/api/api.go deleted file mode 100644 index 4e95c7a0..00000000 --- a/internal/api/api.go +++ /dev/null @@ -1,71 +0,0 @@ -// Package api is a temporary holding pen for GraphQL operations that the -// massdriver-sdk-go doesn't expose yet. Today this is just the resource-type -// surface (Get / List / Publish / Delete). When the SDK grows native support -// the corresponding files here disappear; once the package is empty, delete it. -package api - -import ( - "errors" - "fmt" - "strings" - - "github.com/Khan/genqlient/graphql" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql" -) - -// transportOverride is set by tests to short-circuit transport construction -// (we can't reach inside *massdriver.Client to get its graphql client, so -// tests need their own injection point). Production code leaves it nil and -// gqlClient builds a real transport from the resolved config. -var transportOverride graphql.Client - -// SetTransportForTest installs a graphql.Client that every api operation will -// use instead of the configured Massdriver transport. Tests pair this with -// gqltest.NewClient and t.Cleanup to scrub on teardown. -func SetTransportForTest(c graphql.Client) func() { - transportOverride = c - return func() { transportOverride = nil } -} - -// gqlClient builds a v2-shape GraphQL client from a *massdriver.Client's -// resolved config. Each call reconstructs the transport — cheap, and avoids -// stashing state in this package. -func gqlClient(mdClient *massdriver.Client) graphql.Client { - if transportOverride != nil { - return transportOverride - } - return gql.NewV2Client(mdClient.Config()) -} - -// mutationMessage is the per-field message bag returned by GraphQL mutations. -type mutationMessage struct { - Code string `json:"code"` - Field string `json:"field"` - Message string `json:"message"` -} - -// mutationError formats one or more mutation messages into a single error -// matching the legacy CLI's user-facing output. -func mutationError(label string, messages []mutationMessage) error { - if len(messages) == 0 { - return fmt.Errorf("%s: server reported failure with no detail", label) - } - var b strings.Builder - b.WriteString(label) - b.WriteByte(':') - for _, m := range messages { - b.WriteString("\n - ") - if m.Field != "" { - b.WriteString(m.Field) - b.WriteString(": ") - } - b.WriteString(m.Message) - if m.Code != "" { - b.WriteString(" (") - b.WriteString(m.Code) - b.WriteByte(')') - } - } - return errors.New(b.String()) -} diff --git a/internal/api/resource_type.go b/internal/api/resource_type.go deleted file mode 100644 index 52b34098..00000000 --- a/internal/api/resource_type.go +++ /dev/null @@ -1,241 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/Khan/genqlient/graphql" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql/scalars" -) - -// ResourceType mirrors the v2 GraphQL schema's resource-type record. Field -// names match the JSON wire shape so handcrafted GraphQL responses decode -// without bespoke mapping. -type ResourceType struct { - ID string `json:"id"` - Name string `json:"name"` - Icon string `json:"icon,omitempty"` - ConnectionOrientation string `json:"connectionOrientation"` - Schema map[string]any `json:"schema,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// PublishResourceTypeInput is the input for PublishResourceType. -type PublishResourceTypeInput struct { - Schema map[string]any `json:"schema"` -} - -// resourceTypeMutationResult is the wrapped payload every resource-type -// mutation returns. -type resourceTypeMutationResult struct { - Result *ResourceType `json:"result"` - Successful bool `json:"successful"` - Messages []mutationMessage `json:"messages"` -} - -const getResourceTypeQuery = `query getResourceType($organizationId: ID!, $id: ID!) { - resourceType(organizationId: $organizationId, id: $id) { - id - name - icon - connectionOrientation - schema - createdAt - updatedAt - } -}` - -// resourceTypesPageSize is the per-request page size for the ListResourceTypes -// page-walk. 100 is the server's documented max, minimizing round-trips; the -// value also keeps the cursor arg non-null (see ListResourceTypes). -const resourceTypesPageSize = 100 - -const listResourceTypesQuery = `query listResourceTypes($organizationId: ID!, $cursor: Cursor) { - resourceTypes(organizationId: $organizationId, cursor: $cursor) { - items { - id - name - icon - connectionOrientation - createdAt - updatedAt - } - cursor { - next - previous - } - } -}` - -const publishResourceTypeMutation = `mutation publishResourceType($organizationId: ID!, $input: PublishResourceTypeInput!) { - publishResourceType(organizationId: $organizationId, input: $input) { - result { - id - name - icon - connectionOrientation - schema - createdAt - updatedAt - } - successful - messages { - code - field - message - } - } -}` - -const deleteResourceTypeMutation = `mutation deleteResourceType($organizationId: ID!, $id: ID!) { - deleteResourceType(organizationId: $organizationId, id: $id) { - result { - id - name - } - successful - messages { - code - field - message - } - } -}` - -// GetResourceType fetches a single resource type by name. -func GetResourceType(ctx context.Context, mdClient *massdriver.Client, name string) (*ResourceType, error) { - cfg := mdClient.Config() - var resp struct { - ResourceType *ResourceType `json:"resourceType"` - } - req := &graphql.Request{ - OpName: "getResourceType", - Query: getResourceTypeQuery, - Variables: map[string]any{ - "organizationId": cfg.OrganizationID, - "id": name, - }, - } - if err := gqlClient(mdClient).MakeRequest(ctx, req, &graphql.Response{Data: &resp}); err != nil { - return nil, fmt.Errorf("get resource type %s: %w", name, err) - } - if resp.ResourceType == nil { - return nil, fmt.Errorf("get resource type %s: %w", name, gql.ErrNotFound) - } - return resp.ResourceType, nil -} - -// ListResourceTypes fetches every resource type in the configured organization. -// The legacy CLI supported a filter argument; the few callsites that survive -// the v2 migration only need the unfiltered list. -// -// Resource types aren't in the SDK yet, so the cursor page-walk the SDK does for -// its own list endpoints is implemented here by hand: the server returns one -// page at a time, so we follow cursor.next until it's empty and accumulate every -// page. (The prior version requested only `items` with no cursor, silently -// truncating the result to the server's default first page.) -func ListResourceTypes(ctx context.Context, mdClient *massdriver.Client) ([]ResourceType, error) { - cfg := mdClient.Config() - client := gqlClient(mdClient) - - var all []ResourceType - after := "" - for { - var resp struct { - ResourceTypes struct { - Items []ResourceType `json:"items"` - Cursor struct { - Next string `json:"next"` - Previous string `json:"previous"` - } `json:"cursor"` - } `json:"resourceTypes"` - } - req := &graphql.Request{ - OpName: "listResourceTypes", - Query: listResourceTypesQuery, - Variables: map[string]any{ - "organizationId": cfg.OrganizationID, - // Always send an explicit page size: the server returns 500 on a - // `cursor: null` arg, which is what NewCursor(0, "") would - // produce on the first request. A positive limit makes NewCursor - // emit `{limit, next}` instead. `after` is the prior page's next - // cursor ("" on the first request). - "cursor": scalars.NewCursor(resourceTypesPageSize, after), - }, - } - if err := client.MakeRequest(ctx, req, &graphql.Response{Data: &resp}); err != nil { - return nil, fmt.Errorf("list resource types: %w", err) - } - all = append(all, resp.ResourceTypes.Items...) - - // Stop at the last page. The `next == after` guard is a belt-and-braces - // defense against a server that echoes the same cursor, which would - // otherwise loop forever. - next := resp.ResourceTypes.Cursor.Next - if next == "" || next == after { - break - } - after = next - } - return all, nil -} - -// PublishResourceType registers a resource-type schema. -func PublishResourceType(ctx context.Context, mdClient *massdriver.Client, input PublishResourceTypeInput) (*ResourceType, error) { - cfg := mdClient.Config() - - // The schema field is a GraphQL `Map!` scalar — wire format is a - // JSON-encoded string. scalars.MarshalJSON is the canonical encoder the - // genqlient codegen uses; reuse it so the wire shape stays in lockstep. - schemaRaw, err := scalars.MarshalJSON(input.Schema) - if err != nil { - return nil, fmt.Errorf("marshal resource-type schema: %w", err) - } - - var resp struct { - PublishResourceType resourceTypeMutationResult `json:"publishResourceType"` - } - req := &graphql.Request{ - OpName: "publishResourceType", - Query: publishResourceTypeMutation, - Variables: map[string]any{ - "organizationId": cfg.OrganizationID, - "input": map[string]any{"schema": json.RawMessage(schemaRaw)}, - }, - } - if err := gqlClient(mdClient).MakeRequest(ctx, req, &graphql.Response{Data: &resp}); err != nil { - return nil, fmt.Errorf("publish resource type: %w", err) - } - if !resp.PublishResourceType.Successful { - return nil, mutationError("publish resource type", resp.PublishResourceType.Messages) - } - return resp.PublishResourceType.Result, nil -} - -// DeleteResourceType removes a resource type by name. -func DeleteResourceType(ctx context.Context, mdClient *massdriver.Client, name string) (*ResourceType, error) { - cfg := mdClient.Config() - var resp struct { - DeleteResourceType resourceTypeMutationResult `json:"deleteResourceType"` - } - req := &graphql.Request{ - OpName: "deleteResourceType", - Query: deleteResourceTypeMutation, - Variables: map[string]any{ - "organizationId": cfg.OrganizationID, - "id": name, - }, - } - if err := gqlClient(mdClient).MakeRequest(ctx, req, &graphql.Response{Data: &resp}); err != nil { - return nil, fmt.Errorf("delete resource type %s: %w", name, err) - } - if !resp.DeleteResourceType.Successful { - return nil, mutationError("delete resource type "+name, resp.DeleteResourceType.Messages) - } - return resp.DeleteResourceType.Result, nil -} diff --git a/internal/resourcetype/get.go b/internal/resourcetype/get.go index 61cb618d..b1dce7bd 100644 --- a/internal/resourcetype/get.go +++ b/internal/resourcetype/get.go @@ -1,26 +1,25 @@ -// Package resourcetype provides CLI helpers around resource-type operations. -// -// The underlying GraphQL surface lives in [github.com/massdriver-cloud/mass/internal/api], -// a temporary holding pen for ops not yet exposed by the Massdriver SDK. When -// the SDK adds native resource-type support this package collapses to thin -// wrappers over the SDK and `internal/api` is deleted. +// Package resourcetype provides CLI helpers around resource-type operations, +// thin wrappers over the Massdriver SDK's resource-type and OCI-repo services. package resourcetype import ( "context" "encoding/json" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/ocirepos" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/resourcetypes" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" ) -// ResourceType is an alias of [api.ResourceType] so consumers stay decoupled -// from the holding-pen package import. -type ResourceType = api.ResourceType +// ResourceType is an alias of the SDK's resource-type record so consumers stay +// decoupled from the SDK import path. +type ResourceType = resourcetypes.ResourceType -// Get retrieves a resource type by name from the Massdriver API. +// Get retrieves a resource type by name (optionally `name@version`) from +// Massdriver, including its resolved JSON schema. func Get(ctx context.Context, mdClient *massdriver.Client, resourceTypeName string) (*ResourceType, error) { - return api.GetResourceType(ctx, mdClient, resourceTypeName) + return mdClient.ResourceTypes.Get(ctx, resourceTypeName) } // GetAsMap retrieves a resource type and returns it as a generic map. @@ -40,7 +39,28 @@ func GetAsMap(ctx context.Context, mdClient *massdriver.Client, resourceTypeName return result, unmarshalErr } -// List returns every resource type in the configured organization. +// List returns every resource type in the configured organization, sourced from +// the OCI repository catalog filtered to resource-type artifacts. The returned +// records carry only catalog metadata (ID, name, icon, timestamps); use [Get] +// to fetch a single resource type's schema. func List(ctx context.Context, mdClient *massdriver.Client) ([]ResourceType, error) { - return api.ListResourceTypes(ctx, mdClient) + seq := mdClient.OciRepos.Iter(ctx, ocirepos.ListInput{ + ArtifactType: ocirepos.ArtifactTypeResourceType, + }) + repos, collectErr := types.Collect(seq) + if collectErr != nil { + return nil, collectErr + } + + resourceTypes := make([]ResourceType, len(repos)) + for i, repo := range repos { + resourceTypes[i] = ResourceType{ + ID: repo.ID, + Name: repo.Name, + Icon: repo.Icon, + CreatedAt: repo.CreatedAt, + UpdatedAt: repo.UpdatedAt, + } + } + return resourceTypes, nil } diff --git a/internal/resourcetype/get_test.go b/internal/resourcetype/get_test.go index 104f7cc4..6a22239d 100644 --- a/internal/resourcetype/get_test.go +++ b/internal/resourcetype/get_test.go @@ -1,99 +1,17 @@ package resourcetype_test import ( - "reflect" "testing" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/resourcetype" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql/gqltest" ) -func TestGet(t *testing.T) { - type test struct { - name string - resourceType map[string]any - want resourcetype.ResourceType - } - tests := []test{ - { - name: "simple", - resourceType: map[string]any{ - "id": "123-456", - "name": "massdriver/test-schema", - "schema": map[string]any{ - "$id": "https://example.com/schemas/test-schema.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "A test schema for demonstration purposes.", - }, - }, - want: resourcetype.ResourceType{ - ID: "123-456", - Name: "massdriver/test-schema", - Schema: map[string]any{ - "$id": "https://example.com/schemas/test-schema.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "A test schema for demonstration purposes.", - }, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - mock := gqltest.NewClient( - gqltest.RespondWithData(map[string]any{ - "resourceType": tc.resourceType, - }), - ) - t.Cleanup(api.SetTransportForTest(mock)) - mdClient, err := massdriver.NewClient( - massdriver.WithGQLClient(mock), - massdriver.WithOrganizationID("test-org"), - ) - if err != nil { - t.Fatal(err) - } - - got, err := resourcetype.Get(t.Context(), mdClient, "massdriver/test-schema") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !reflect.DeepEqual(*got, tc.want) { - t.Errorf("got %v, want %v", *got, tc.want) - } - }) - } -} - -// TestListWalksPages verifies that List follows the API's cursor pagination and -// accumulates every page, rather than returning only the server's first page. -func TestListWalksPages(t *testing.T) { - page := func(items []map[string]any, next string) map[string]any { - return map[string]any{ - "resourceTypes": map[string]any{ - "items": items, - "cursor": map[string]any{ - "next": next, - "previous": "", - }, - }, - } - } - - mock := gqltest.NewClient( - gqltest.RespondWithData(page([]map[string]any{ - {"id": "rt-1", "name": "aws/vpc"}, - {"id": "rt-2", "name": "aws/s3"}, - }, "cursor-2")), - gqltest.RespondWithData(page([]map[string]any{ - {"id": "rt-3", "name": "gcp/bucket"}, - }, "")), - ) - t.Cleanup(api.SetTransportForTest(mock)) +func newMockClient(t *testing.T, responses ...gqltest.Response) *massdriver.Client { + t.Helper() + mock := gqltest.NewClient(responses...) mdClient, err := massdriver.NewClient( massdriver.WithGQLClient(mock), massdriver.WithOrganizationID("test-org"), @@ -101,39 +19,61 @@ func TestListWalksPages(t *testing.T) { if err != nil { t.Fatal(err) } + return mdClient +} - got, err := resourcetype.List(t.Context(), mdClient) +func TestGet(t *testing.T) { + mdClient := newMockClient(t, gqltest.RespondWithData(map[string]any{ + "resourceType": map[string]any{ + "id": "aws-s3-bucket", + "name": "AWS S3 Bucket", + "schema": map[string]any{ + "$id": "https://example.com/schemas/test-schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "A test schema for demonstration purposes.", + }, + }, + })) + + got, err := resourcetype.Get(t.Context(), mdClient, "aws-s3-bucket") if err != nil { t.Fatalf("unexpected error: %v", err) } - - // All three resource types, across both pages, must be accumulated. - wantIDs := []string{"rt-1", "rt-2", "rt-3"} - if len(got) != len(wantIDs) { - t.Fatalf("got %d resource types, want %d (page walk should accumulate all pages): %+v", len(got), len(wantIDs), got) + if got.ID != "aws-s3-bucket" { + t.Errorf("ID = %q, want aws-s3-bucket", got.ID) } - for i, id := range wantIDs { - if got[i].ID != id { - t.Errorf("resource type %d: got id %q, want %q", i, got[i].ID, id) - } + if got.Name != "AWS S3 Bucket" { + t.Errorf("Name = %q, want AWS S3 Bucket", got.Name) } - - // Two requests, each carrying an explicit page-size limit (a null cursor - // 500s the server). The first has no `next`; the second carries the prior - // page's next cursor. - reqs := mock.Requests() - if len(reqs) != 2 { - t.Fatalf("got %d requests, want 2 (should follow cursor.next)", len(reqs)) + if _, ok := got.Schema["$id"]; !ok { + t.Errorf("Schema should carry the resolved JSON schema, got %v", got.Schema) } - cursor1, ok := reqs[0].Variables["cursor"].(map[string]any) - if !ok || cursor1["limit"] == nil || cursor1["next"] != nil { - t.Errorf("first request should send a limit and no next, got %v", reqs[0].Variables["cursor"]) +} + +// TestList verifies List sources from the OCI-repo catalog filtered to +// resource-type artifacts and maps each repo into a ResourceType. +func TestList(t *testing.T) { + mdClient := newMockClient(t, gqltest.RespondWithData(map[string]any{ + "ociRepos": map[string]any{ + "cursor": map[string]any{}, + "items": []map[string]any{ + {"id": "aws-vpc", "name": "aws-vpc", "artifactType": "application/vnd.massdriver.resource-type.v1+json"}, + {"id": "aws-s3", "name": "aws-s3", "artifactType": "application/vnd.massdriver.resource-type.v1+json"}, + }, + }, + })) + + got, err := resourcetype.List(t.Context(), mdClient) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - cursor2, ok := reqs[1].Variables["cursor"].(map[string]any) - if !ok || cursor2["next"] != "cursor-2" { - t.Errorf("second request should carry next=cursor-2, got %v", reqs[1].Variables["cursor"]) + if len(got) != 2 { + t.Fatalf("got %d resource types, want 2: %+v", len(got), got) } - if pending := mock.Pending(); pending != 0 { - t.Errorf("expected all queued responses consumed, %d pending", pending) + wantIDs := []string{"aws-vpc", "aws-s3"} + for i, id := range wantIDs { + if got[i].ID != id || got[i].Name != id { + t.Errorf("resource type %d: got id=%q name=%q, want %q", i, got[i].ID, got[i].Name, id) + } } } From b3d6cfa14c8c0414aeb3b6861da6b0bfd382e7b7 Mon Sep 17 00:00:00 2001 From: chrisghill Date: Mon, 10 Aug 2026 22:22:53 -0600 Subject: [PATCH 03/19] fix instructions and export template paths --- docs/helpdocs/type/publish.md | 4 +- internal/resourcetype/keep_test.go | 111 +++++++++++++++++++++++++---- internal/resourcetype/publish.go | 94 ++++++++++++++++++++---- 3 files changed, 179 insertions(+), 30 deletions(-) diff --git a/docs/helpdocs/type/publish.md b/docs/helpdocs/type/publish.md index 0fde61b7..fda87ff2 100644 --- a/docs/helpdocs/type/publish.md +++ b/docs/helpdocs/type/publish.md @@ -17,8 +17,8 @@ mass resource-type publish [path] `path` is a directory containing a `massdriver.yaml` (defaults to the current directory). Only `massdriver.yaml`, `readme`, `changelog`, icon files, and the -`instructions/` and `exports/` directories referenced by the `massdriver.yaml` -are included in the published artifact. +instruction/export template files referenced by the `massdriver.yaml` are +included in the published artifact. ## Examples diff --git a/internal/resourcetype/keep_test.go b/internal/resourcetype/keep_test.go index d62d11ff..30384400 100644 --- a/internal/resourcetype/keep_test.go +++ b/internal/resourcetype/keep_test.go @@ -1,9 +1,27 @@ package resourcetype //nolint:testpackage // needs access to unexported packageKeep -import "testing" +import ( + "os" + "path/filepath" + "strings" + "testing" +) func TestPackageKeep(t *testing.T) { - keep := []string{ + config := &MassdriverYAML{ + UI: &UIConfig{ + Instructions: []InstructionConfig{ + {Label: "CLI", Path: "./docs/cli.md"}, + {Label: "Console", Path: "instructions/console.md"}, + }, + }, + Exports: []ExportConfig{ + {DownloadButtonText: "Config", TemplatePath: "./templates/config.yaml.liquid"}, + }, + } + keep := packageKeep(config) + + admit := []string{ "massdriver.yaml", "README.md", "readme.md", @@ -12,28 +30,93 @@ func TestPackageKeep(t *testing.T) { "icon.png", "icon.jpg", "icon.jpeg", - "instructions/cli.md", - "instructions/nested/deep.md", - "exports/config.yaml.liquid", + "docs/cli.md", // referenced instruction, arbitrary dir + "instructions/console.md", // referenced instruction + "templates/config.yaml.liquid", // referenced export template } - drop := []string{ + skip := []string{ "main.tf", "schema-params.json", "icon.gif", ".mdignore", - "instructions", // the bare name, not a file under the dir - "docs/readme.md", + "docs/other.md", // unreferenced file in a referenced dir + "instructions/cli.md", // not the referenced instruction path "secrets/key.pem", } - for _, f := range keep { - if !packageKeep(f) { - t.Errorf("packageKeep(%q) = false, want true", f) + for _, f := range admit { + if !keep(f) { + t.Errorf("keep(%q) = false, want true", f) } } - for _, f := range drop { - if packageKeep(f) { - t.Errorf("packageKeep(%q) = true, want false", f) + for _, f := range skip { + if keep(f) { + t.Errorf("keep(%q) = true, want false", f) } } } + +func TestPackageKeepNoReferences(t *testing.T) { + keep := packageKeep(&MassdriverYAML{}) + if !keep("massdriver.yaml") { + t.Error("massdriver.yaml should always be kept") + } + if keep("instructions/cli.md") { + t.Error("nothing under instructions/ should be kept when unreferenced") + } +} + +func TestValidateReferencedFiles(t *testing.T) { + srcDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(srcDir, "docs"), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(srcDir, "docs", "cli.md"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(srcDir, "tmpl.liquid"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + + uiWith := func(path string) *UIConfig { + return &UIConfig{Instructions: []InstructionConfig{{Label: "L", Path: path}}} + } + + t.Run("all references present and inside the tree", func(t *testing.T) { + config := &MassdriverYAML{ + UI: uiWith("./docs/cli.md"), + Exports: []ExportConfig{{TemplatePath: "tmpl.liquid"}}, + } + if err := validateReferencedFiles(config, srcDir); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("missing file is rejected", func(t *testing.T) { + err := validateReferencedFiles(&MassdriverYAML{UI: uiWith("./docs/missing.md")}, srcDir) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("want not-found error, got: %v", err) + } + }) + + t.Run("path escaping the directory is rejected", func(t *testing.T) { + err := validateReferencedFiles(&MassdriverYAML{UI: uiWith("../secret.md")}, srcDir) + if err == nil || !strings.Contains(err.Error(), "inside the resource type directory") { + t.Fatalf("want outside-directory error, got: %v", err) + } + }) + + t.Run("absolute path is rejected", func(t *testing.T) { + err := validateReferencedFiles(&MassdriverYAML{Exports: []ExportConfig{{TemplatePath: "/etc/passwd"}}}, srcDir) + if err == nil || !strings.Contains(err.Error(), "inside the resource type directory") { + t.Fatalf("want outside-directory error, got: %v", err) + } + }) + + t.Run("directory reference is rejected", func(t *testing.T) { + err := validateReferencedFiles(&MassdriverYAML{UI: uiWith("./docs")}, srcDir) + if err == nil || !strings.Contains(err.Error(), "is a directory") { + t.Fatalf("want is-a-directory error, got: %v", err) + } + }) +} diff --git a/internal/resourcetype/publish.go b/internal/resourcetype/publish.go index a03af966..5b9ee5c1 100644 --- a/internal/resourcetype/publish.go +++ b/internal/resourcetype/publish.go @@ -30,23 +30,83 @@ var allowedFiles = map[string]bool{ "icon.jpeg": true, } -// allowedDirs are the subdirectories a massdriver.yaml may reference (UI -// instructions and export templates); their contents are packaged too. -var allowedDirs = []string{"instructions/", "exports/"} - -// packageKeep is the keep predicate used when packaging a resource type. It -// admits the allowlisted top-level files plus anything under the referenced -// instruction/export directories. -func packageKeep(relPath string) bool { - for _, dir := range allowedDirs { - if strings.HasPrefix(relPath, dir) { +// referencedPaths returns the raw instruction and export template file +// references declared in a massdriver.yaml, in declaration order. +func referencedPaths(config *MassdriverYAML) []string { + var refs []string + if config.UI != nil { + for _, inst := range config.UI.Instructions { + refs = append(refs, inst.Path) + } + } + for _, exp := range config.Exports { + refs = append(refs, exp.TemplatePath) + } + return refs +} + +// packageKeep builds the keep predicate used when packaging a resource type. +// It admits the allowlisted top-level files plus the exact instruction and +// export template files the massdriver.yaml references (wherever they live in +// the directory tree), and silently skips everything else. +func packageKeep(config *MassdriverYAML) func(relPath string) bool { + referenced := map[string]bool{} + for _, p := range referencedPaths(config) { + if norm := normalizeRel(p); norm != "" { + referenced[norm] = true + } + } + + return func(relPath string) bool { + if referenced[relPath] { return true } + if strings.Contains(relPath, "/") { + return false + } + return allowedFiles[strings.ToLower(relPath)] + } +} + +// validateReferencedFiles ensures every instruction/export file the +// massdriver.yaml references resolves to a real file inside srcDir. References +// that are absolute, escape the directory, or don't exist would be dropped by +// the packager and produce a silently incomplete artifact, so they're rejected +// up front. +func validateReferencedFiles(config *MassdriverYAML, srcDir string) error { + for _, ref := range referencedPaths(config) { + if ref == "" { + continue + } + norm := normalizeRel(ref) + if norm == "" { + return fmt.Errorf("referenced file %q must live inside the resource type directory (absolute paths and paths outside the directory can't be packaged)", ref) + } + info, statErr := os.Stat(filepath.Join(srcDir, norm)) + if statErr != nil { + return fmt.Errorf("referenced file %q was not found in the resource type directory: %w", ref, statErr) + } + if info.IsDir() { + return fmt.Errorf("referenced file %q is a directory, not a file", ref) + } } - if strings.Contains(relPath, "/") { - return false + return nil +} + +// normalizeRel converts a massdriver.yaml file reference (relative to the +// massdriver.yaml, e.g. "./instructions/cli.md") into the slash-separated, +// cleaned form the packager's keep predicate receives. Empty and non-local +// (absolute or parent-escaping) references return "" since they can't match a +// file walked under the resource type directory. +func normalizeRel(p string) string { + if p == "" { + return "" + } + cleaned := filepath.ToSlash(filepath.Clean(p)) + if cleaned == "." || filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "../") { + return "" } - return allowedFiles[strings.ToLower(relPath)] + return cleaned } // Publish validates a resource type located at path and pushes it to its OCI @@ -70,6 +130,12 @@ func Publish(ctx context.Context, mdClient *massdriver.Client, path string) (str return "", "", fmt.Errorf("version is required in %s", mdYamlPath) } + // Referenced instruction/export files must live inside the packaged + // directory, otherwise the artifact would ship incomplete. + if refErr := validateReferencedFiles(config, srcDir); refErr != nil { + return "", "", refErr + } + // Fail fast on a duplicate version before the network-heavy schema // dereference and validation. if versionErr := checkDuplicateVersion(ctx, mdClient, config.Name, config.Version); versionErr != nil { @@ -90,7 +156,7 @@ func Publish(ctx context.Context, mdClient *massdriver.Client, path string) (str Repo: repo, } - if _, packageErr := publisher.Package(ctx, srcDir, config.Version, ArtifactType, packageKeep); packageErr != nil { + if _, packageErr := publisher.Package(ctx, srcDir, config.Version, ArtifactType, packageKeep(config)); packageErr != nil { return "", "", fmt.Errorf("packaging resource type: %w", packageErr) } From 883054bcf06b4a1c6bb0abc213029405ce81ab49 Mon Sep 17 00:00:00 2001 From: chrisghill Date: Tue, 11 Aug 2026 17:32:13 -0600 Subject: [PATCH 04/19] remove unnecessary (and confusing) check --- internal/resourcetype/publish.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/internal/resourcetype/publish.go b/internal/resourcetype/publish.go index 5b9ee5c1..4b15ddde 100644 --- a/internal/resourcetype/publish.go +++ b/internal/resourcetype/publish.go @@ -58,13 +58,7 @@ func packageKeep(config *MassdriverYAML) func(relPath string) bool { } return func(relPath string) bool { - if referenced[relPath] { - return true - } - if strings.Contains(relPath, "/") { - return false - } - return allowedFiles[strings.ToLower(relPath)] + return referenced[relPath] || allowedFiles[strings.ToLower(relPath)] } } From fc020d262a82d043a60cf098c3078d13f85bb83c Mon Sep 17 00:00:00 2001 From: chrisghill Date: Wed, 12 Aug 2026 14:09:21 -0600 Subject: [PATCH 05/19] reorganize files --- cmd/resource_type.go | 13 ++-- .../{ => commands}/resourcetype/convert.go | 66 ++++++++++--------- .../resourcetype/convert_test.go | 41 +++++++----- .../{ => commands}/resourcetype/delete.go | 6 +- .../{ => commands}/resourcetype/keep_test.go | 30 +++++---- .../{ => commands}/resourcetype/publish.go | 23 +++---- .../resourcetype/publish_test.go | 14 ++-- internal/{ => commands}/resourcetype/pull.go | 4 +- .../testdata/simple-resource.json | 16 +++++ internal/resourcetype/build.go | 3 + 10 files changed, 125 insertions(+), 91 deletions(-) rename internal/{ => commands}/resourcetype/convert.go (71%) rename internal/{ => commands}/resourcetype/convert_test.go (57%) rename internal/{ => commands}/resourcetype/delete.go (74%) rename internal/{ => commands}/resourcetype/keep_test.go (75%) rename internal/{ => commands}/resourcetype/publish.go (89%) rename internal/{ => commands}/resourcetype/publish_test.go (71%) rename internal/{ => commands}/resourcetype/pull.go (90%) create mode 100644 internal/commands/resourcetype/testdata/simple-resource.json diff --git a/cmd/resource_type.go b/cmd/resource_type.go index e6f247bd..7c4a2aa5 100644 --- a/cmd/resource_type.go +++ b/cmd/resource_type.go @@ -16,6 +16,7 @@ import ( "github.com/charmbracelet/glamour" "github.com/massdriver-cloud/mass/docs/helpdocs" "github.com/massdriver-cloud/mass/internal/cli" + cmdresourcetype "github.com/massdriver-cloud/mass/internal/commands/resourcetype" "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/mass/internal/resourcetype" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" @@ -197,7 +198,7 @@ func runTypePublish(cmd *cobra.Command, args []string) error { return fmt.Errorf("error initializing massdriver client: %w", err) } - name, version, publishErr := resourcetype.Publish(ctx, mdClient, path) + name, version, publishErr := cmdresourcetype.RunPublish(ctx, mdClient, path) if publishErr != nil { return fmt.Errorf("error publishing resource type: %w", publishErr) } @@ -236,7 +237,7 @@ func runTypePull(cmd *cobra.Command, args []string) error { return fmt.Errorf("error initializing massdriver client: %w", err) } - tag, digest, pullErr := resourcetype.Pull(ctx, mdClient, name, version, directory) + tag, digest, pullErr := cmdresourcetype.RunPull(ctx, mdClient, name, version, directory) if pullErr != nil { return fmt.Errorf("error pulling resource type: %w", pullErr) } @@ -316,8 +317,8 @@ func runTypeDelete(cmd *cobra.Command, args []string) error { // Fail before the confirmation prompt if the repo is immutable (has published // versions) — no point making the user type the name for a delete that can't - // succeed. resourcetype.Delete re-checks to guard against a version being - // published during the prompt. + // succeed. RunDelete re-checks to guard against a version being published + // during the prompt. if len(repo.Tags) > 0 { return fmt.Errorf("resource type %s has published versions and is immutable; its repository cannot be deleted", repo.Name) } @@ -335,7 +336,7 @@ func runTypeDelete(cmd *cobra.Command, args []string) error { } } - deleted, deleteErr := resourcetype.Delete(ctx, mdClient, name) + deleted, deleteErr := cmdresourcetype.RunDelete(ctx, mdClient, name) if deleteErr != nil { return fmt.Errorf("error deleting resource type: %w", deleteErr) } @@ -356,7 +357,7 @@ func runTypeConvert(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - result, convertErr := resourcetype.Convert(schemaPath, output, force) + result, convertErr := cmdresourcetype.RunConvert(schemaPath, output, force) if convertErr != nil { return fmt.Errorf("error converting resource type: %w", convertErr) } diff --git a/internal/resourcetype/convert.go b/internal/commands/resourcetype/convert.go similarity index 71% rename from internal/resourcetype/convert.go rename to internal/commands/resourcetype/convert.go index b91c7327..f53e1b3c 100644 --- a/internal/resourcetype/convert.go +++ b/internal/commands/resourcetype/convert.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "github.com/massdriver-cloud/mass/internal/resourcetype" "gopkg.in/yaml.v3" ) @@ -17,18 +18,18 @@ import ( // publishing. const placeholderVersion = "0.0.0" -// ConvertResult describes the files a Convert call produced. +// ConvertResult describes the files a RunConvert call produced. type ConvertResult struct { MassdriverYAML string // path to the written massdriver.yaml ExtraFiles []string // paths to extracted instruction/export files } -// Convert reads a raw JSON (or YAML) resource type schema at schemaPath and +// RunConvert reads a raw JSON (or YAML) resource type schema at schemaPath and // writes an equivalent massdriver.yaml. Inlined instruction/export content is // extracted back out to referenced files. outputPath is the massdriver.yaml to // write; when empty it defaults to a massdriver.yaml alongside schemaPath. // Existing files are not overwritten unless force is set. -func Convert(schemaPath, outputPath string, force bool) (*ConvertResult, error) { +func RunConvert(schemaPath, outputPath string, force bool) (*ConvertResult, error) { raw, readErr := readRawSchema(schemaPath) if readErr != nil { return nil, readErr @@ -104,11 +105,12 @@ func readRawSchema(path string) (map[string]any, error) { return raw, nil } -// reverseBuild is the inverse of [Build]: it lifts the `$md` block back into the -// massdriver.yaml fields, extracts inlined instruction/export content into files -// keyed by their relative path, and moves the remaining keys under `schema`. -func reverseBuild(raw map[string]any) (*MassdriverYAML, map[string][]byte) { - config := &MassdriverYAML{Version: placeholderVersion} +// reverseBuild is the inverse of resourcetype.Build: it lifts the `$md` block +// back into the massdriver.yaml fields, extracts inlined instruction/export +// content into files keyed by their relative path, and moves the remaining keys +// under `schema`. +func reverseBuild(raw map[string]any) (*resourcetype.MassdriverYAML, map[string][]byte) { + config := &resourcetype.MassdriverYAML{Version: placeholderVersion} extraFiles := map[string][]byte{} if md, ok := raw["$md"].(map[string]any); ok { @@ -138,8 +140,8 @@ func reverseBuild(raw map[string]any) (*MassdriverYAML, map[string][]byte) { return config, extraFiles } -func reverseUI(uiRaw map[string]any, extraFiles map[string][]byte) *UIConfig { - ui := &UIConfig{ +func reverseUI(uiRaw map[string]any, extraFiles map[string][]byte) *resourcetype.UIConfig { + ui := &resourcetype.UIConfig{ ConnectionOrientation: asString(uiRaw["connectionOrientation"]), EnvironmentDefaultGroup: asString(uiRaw["environmentDefaultGroup"]), } @@ -154,9 +156,9 @@ func reverseUI(uiRaw map[string]any, extraFiles map[string][]byte) *UIConfig { continue } label := asString(inst["label"]) - rel := uniqueRel(extraFiles, "instructions", slugify(label, i), "md", i) + rel := uniqueRel(extraFiles, "instructions", sanitize(label, i), "md") extraFiles[rel] = []byte(asString(inst["content"])) - ui.Instructions = append(ui.Instructions, InstructionConfig{ + ui.Instructions = append(ui.Instructions, resourcetype.InstructionConfig{ Label: label, Path: "./" + rel, }) @@ -164,8 +166,8 @@ func reverseUI(uiRaw map[string]any, extraFiles map[string][]byte) *UIConfig { return ui } -func reverseExports(exportsRaw []any, extraFiles map[string][]byte) []ExportConfig { - var exports []ExportConfig +func reverseExports(exportsRaw []any, extraFiles map[string][]byte) []resourcetype.ExportConfig { + var exports []resourcetype.ExportConfig for i, expRaw := range exportsRaw { exp, ok := expRaw.(map[string]any) if !ok { @@ -176,9 +178,9 @@ func reverseExports(exportsRaw []any, extraFiles map[string][]byte) []ExportConf if ext == "" { ext = "tmpl" } - rel := uniqueRel(extraFiles, "exports", slugify(asString(exp["downloadButtonText"]), i), ext, i) + rel := uniqueRel(extraFiles, "exports", sanitize(asString(exp["downloadButtonText"]), i), ext) extraFiles[rel] = []byte(asString(exp["template"])) - exports = append(exports, ExportConfig{ + exports = append(exports, resourcetype.ExportConfig{ DownloadButtonText: asString(exp["downloadButtonText"]), FileFormat: asString(exp["fileFormat"]), TemplatePath: "./" + rel, @@ -188,14 +190,18 @@ func reverseExports(exportsRaw []any, extraFiles map[string][]byte) []ExportConf return exports } -// uniqueRel builds "/.", appending the item index if that path -// was already taken so two items with the same label don't clobber each other. -func uniqueRel(extraFiles map[string][]byte, dir, slug, ext string, index int) string { - rel := fmt.Sprintf("%s/%s.%s", dir, slug, ext) - if _, taken := extraFiles[rel]; !taken { - return rel +// uniqueRel builds "/.", appending an incrementing numeric +// suffix until the path is unused, so two items that reduce to the same name +// don't clobber each other's extracted file. +func uniqueRel(extraFiles map[string][]byte, dir, name, ext string) string { + base := fmt.Sprintf("%s/%s", dir, name) + rel := base + "." + ext + for n := 2; ; n++ { + if _, taken := extraFiles[rel]; !taken { + return rel + } + rel = fmt.Sprintf("%s-%d.%s", base, n, ext) } - return fmt.Sprintf("%s/%s-%d.%s", dir, slug, index+1, ext) } func asString(v any) string { @@ -205,15 +211,15 @@ func asString(v any) string { return "" } -var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`) +var nonFilenameChars = regexp.MustCompile(`[^a-z0-9]+`) -// slugify turns a human label into a filesystem-friendly slug, falling back to +// sanitize turns a human label into a filesystem-friendly name, falling back to // an index-based name when the label has no usable characters. -func slugify(label string, index int) string { - slug := nonSlugChars.ReplaceAllString(strings.ToLower(label), "-") - slug = strings.Trim(slug, "-") - if slug == "" { +func sanitize(label string, index int) string { + name := nonFilenameChars.ReplaceAllString(strings.ToLower(label), "-") + name = strings.Trim(name, "-") + if name == "" { return strconv.Itoa(index + 1) } - return slug + return name } diff --git a/internal/resourcetype/convert_test.go b/internal/commands/resourcetype/convert_test.go similarity index 57% rename from internal/resourcetype/convert_test.go rename to internal/commands/resourcetype/convert_test.go index 4baf2643..55a4c7a3 100644 --- a/internal/resourcetype/convert_test.go +++ b/internal/commands/resourcetype/convert_test.go @@ -5,16 +5,17 @@ import ( "path/filepath" "testing" - "github.com/massdriver-cloud/mass/internal/resourcetype" + cmdresourcetype "github.com/massdriver-cloud/mass/internal/commands/resourcetype" + rtype "github.com/massdriver-cloud/mass/internal/resourcetype" "gopkg.in/yaml.v3" ) -func TestConvert(t *testing.T) { +func TestRunConvert(t *testing.T) { out := filepath.Join(t.TempDir(), "massdriver.yaml") - result, err := resourcetype.Convert("testdata/simple-resource.json", out, false) + result, err := cmdresourcetype.RunConvert("testdata/simple-resource.json", out, false) if err != nil { - t.Fatalf("Convert failed: %v", err) + t.Fatalf("RunConvert failed: %v", err) } if result.MassdriverYAML != out { t.Errorf("MassdriverYAML = %q, want %q", result.MassdriverYAML, out) @@ -25,7 +26,7 @@ func TestConvert(t *testing.T) { t.Fatalf("reading output: %v", readErr) } - var config resourcetype.MassdriverYAML + var config rtype.MassdriverYAML if unmarshalErr := yaml.Unmarshal(data, &config); unmarshalErr != nil { t.Fatalf("output is not valid massdriver.yaml: %v", unmarshalErr) } @@ -36,7 +37,6 @@ func TestConvert(t *testing.T) { if config.Version == "" { t.Error("expected a placeholder version to be written") } - // The $md block must be lifted out of the schema. if _, ok := config.Schema["$md"]; ok { t.Error("schema should not contain the $md block after conversion") } @@ -45,14 +45,17 @@ func TestConvert(t *testing.T) { } } -func TestConvertDistinctFilesForDuplicateLabels(t *testing.T) { +func TestRunConvertDistinctFilesForDuplicateLabels(t *testing.T) { dir := t.TempDir() + // Labels crafted to trip the old (buggy) unique-path logic: the third + // instruction's fallback name collided with the first's. raw := `{ "$md": { "name": "dup", "ui": { "instructions": [ - { "label": "Setup", "content": "first" }, - { "label": "Setup", "content": "second" } + { "label": "a 3", "content": "first" }, + { "label": "a", "content": "second" }, + { "label": "a", "content": "third" } ] } }, "type": "object" @@ -63,12 +66,12 @@ func TestConvertDistinctFilesForDuplicateLabels(t *testing.T) { } out := filepath.Join(dir, "out", "massdriver.yaml") - result, err := resourcetype.Convert(schemaPath, out, false) + result, err := cmdresourcetype.RunConvert(schemaPath, out, false) if err != nil { - t.Fatalf("Convert failed: %v", err) + t.Fatalf("RunConvert failed: %v", err) } - if len(result.ExtraFiles) != 2 { - t.Fatalf("expected 2 distinct instruction files, got %d: %v", len(result.ExtraFiles), result.ExtraFiles) + if len(result.ExtraFiles) != 3 { + t.Fatalf("expected 3 distinct instruction files, got %d: %v", len(result.ExtraFiles), result.ExtraFiles) } contents := map[string]bool{} @@ -79,22 +82,24 @@ func TestConvertDistinctFilesForDuplicateLabels(t *testing.T) { } contents[string(data)] = true } - if !contents["first"] || !contents["second"] { - t.Errorf("both instruction contents should be preserved, got: %v", contents) + for _, want := range []string{"first", "second", "third"} { + if !contents[want] { + t.Errorf("instruction content %q was lost to a filename collision, got: %v", want, contents) + } } } -func TestConvertRefusesToClobber(t *testing.T) { +func TestRunConvertRefusesToClobber(t *testing.T) { out := filepath.Join(t.TempDir(), "massdriver.yaml") if err := os.WriteFile(out, []byte("existing"), 0600); err != nil { t.Fatal(err) } - if _, err := resourcetype.Convert("testdata/simple-resource.json", out, false); err == nil { + if _, err := cmdresourcetype.RunConvert("testdata/simple-resource.json", out, false); err == nil { t.Fatal("expected an error when the output file already exists") } - if _, err := resourcetype.Convert("testdata/simple-resource.json", out, true); err != nil { + if _, err := cmdresourcetype.RunConvert("testdata/simple-resource.json", out, true); err != nil { t.Fatalf("expected --force to overwrite, got: %v", err) } } diff --git a/internal/resourcetype/delete.go b/internal/commands/resourcetype/delete.go similarity index 74% rename from internal/resourcetype/delete.go rename to internal/commands/resourcetype/delete.go index 437acab3..ab3a0aa4 100644 --- a/internal/resourcetype/delete.go +++ b/internal/commands/resourcetype/delete.go @@ -8,11 +8,11 @@ import ( "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/ocirepos" ) -// Delete removes a resource type's OCI repository. Because published versions +// RunDelete removes a resource type's OCI repository. Because published versions // are immutable, deletion is refused locally when the repository already has // tags. UX (confirmation prompt, success message) is the caller's -// responsibility — see [cmd.runTypeDelete]. -func Delete(ctx context.Context, mdClient *massdriver.Client, name string) (*ocirepos.OciRepo, error) { +// responsibility — see cmd.runTypeDelete. +func RunDelete(ctx context.Context, mdClient *massdriver.Client, name string) (*ocirepos.OciRepo, error) { repo, getErr := mdClient.OciRepos.Get(ctx, name) if getErr != nil { return nil, fmt.Errorf("fetching OCI repo: %w", getErr) diff --git a/internal/resourcetype/keep_test.go b/internal/commands/resourcetype/keep_test.go similarity index 75% rename from internal/resourcetype/keep_test.go rename to internal/commands/resourcetype/keep_test.go index 30384400..4530aa4a 100644 --- a/internal/resourcetype/keep_test.go +++ b/internal/commands/resourcetype/keep_test.go @@ -1,21 +1,23 @@ -package resourcetype //nolint:testpackage // needs access to unexported packageKeep +package resourcetype //nolint:testpackage // needs access to unexported packageKeep/validateReferencedFiles import ( "os" "path/filepath" "strings" "testing" + + rtype "github.com/massdriver-cloud/mass/internal/resourcetype" ) func TestPackageKeep(t *testing.T) { - config := &MassdriverYAML{ - UI: &UIConfig{ - Instructions: []InstructionConfig{ + config := &rtype.MassdriverYAML{ + UI: &rtype.UIConfig{ + Instructions: []rtype.InstructionConfig{ {Label: "CLI", Path: "./docs/cli.md"}, {Label: "Console", Path: "instructions/console.md"}, }, }, - Exports: []ExportConfig{ + Exports: []rtype.ExportConfig{ {DownloadButtonText: "Config", TemplatePath: "./templates/config.yaml.liquid"}, }, } @@ -57,7 +59,7 @@ func TestPackageKeep(t *testing.T) { } func TestPackageKeepNoReferences(t *testing.T) { - keep := packageKeep(&MassdriverYAML{}) + keep := packageKeep(&rtype.MassdriverYAML{}) if !keep("massdriver.yaml") { t.Error("massdriver.yaml should always be kept") } @@ -78,14 +80,14 @@ func TestValidateReferencedFiles(t *testing.T) { t.Fatal(err) } - uiWith := func(path string) *UIConfig { - return &UIConfig{Instructions: []InstructionConfig{{Label: "L", Path: path}}} + uiWith := func(path string) *rtype.UIConfig { + return &rtype.UIConfig{Instructions: []rtype.InstructionConfig{{Label: "L", Path: path}}} } t.Run("all references present and inside the tree", func(t *testing.T) { - config := &MassdriverYAML{ + config := &rtype.MassdriverYAML{ UI: uiWith("./docs/cli.md"), - Exports: []ExportConfig{{TemplatePath: "tmpl.liquid"}}, + Exports: []rtype.ExportConfig{{TemplatePath: "tmpl.liquid"}}, } if err := validateReferencedFiles(config, srcDir); err != nil { t.Fatalf("unexpected error: %v", err) @@ -93,28 +95,28 @@ func TestValidateReferencedFiles(t *testing.T) { }) t.Run("missing file is rejected", func(t *testing.T) { - err := validateReferencedFiles(&MassdriverYAML{UI: uiWith("./docs/missing.md")}, srcDir) + err := validateReferencedFiles(&rtype.MassdriverYAML{UI: uiWith("./docs/missing.md")}, srcDir) if err == nil || !strings.Contains(err.Error(), "not found") { t.Fatalf("want not-found error, got: %v", err) } }) t.Run("path escaping the directory is rejected", func(t *testing.T) { - err := validateReferencedFiles(&MassdriverYAML{UI: uiWith("../secret.md")}, srcDir) + err := validateReferencedFiles(&rtype.MassdriverYAML{UI: uiWith("../secret.md")}, srcDir) if err == nil || !strings.Contains(err.Error(), "inside the resource type directory") { t.Fatalf("want outside-directory error, got: %v", err) } }) t.Run("absolute path is rejected", func(t *testing.T) { - err := validateReferencedFiles(&MassdriverYAML{Exports: []ExportConfig{{TemplatePath: "/etc/passwd"}}}, srcDir) + err := validateReferencedFiles(&rtype.MassdriverYAML{Exports: []rtype.ExportConfig{{TemplatePath: "/etc/passwd"}}}, srcDir) if err == nil || !strings.Contains(err.Error(), "inside the resource type directory") { t.Fatalf("want outside-directory error, got: %v", err) } }) t.Run("directory reference is rejected", func(t *testing.T) { - err := validateReferencedFiles(&MassdriverYAML{UI: uiWith("./docs")}, srcDir) + err := validateReferencedFiles(&rtype.MassdriverYAML{UI: uiWith("./docs")}, srcDir) if err == nil || !strings.Contains(err.Error(), "is a directory") { t.Fatalf("want is-a-directory error, got: %v", err) } diff --git a/internal/resourcetype/publish.go b/internal/commands/resourcetype/publish.go similarity index 89% rename from internal/resourcetype/publish.go rename to internal/commands/resourcetype/publish.go index 4b15ddde..4f13cf2f 100644 --- a/internal/resourcetype/publish.go +++ b/internal/commands/resourcetype/publish.go @@ -1,3 +1,6 @@ +// Package resourcetype holds the testable logic behind the `mass resource-type` +// commands. The cobra wiring lives in the top-level cmd package; generalized, +// reusable resource-type logic lives in internal/resourcetype. package resourcetype import ( @@ -10,13 +13,11 @@ import ( "github.com/massdriver-cloud/mass/internal/jsonschema" "github.com/massdriver-cloud/mass/internal/oci" + "github.com/massdriver-cloud/mass/internal/resourcetype" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "oras.land/oras-go/v2/content/memory" ) -// ArtifactType is the OCI artifact-type media type for resource types. -const ArtifactType = "application/vnd.massdriver.resource-type.v1+json" - // allowedFiles is the subset of top-level files (matched case-insensitively by // name) that may be packaged into a resource type artifact. Everything else at // the top level is silently skipped. @@ -32,7 +33,7 @@ var allowedFiles = map[string]bool{ // referencedPaths returns the raw instruction and export template file // references declared in a massdriver.yaml, in declaration order. -func referencedPaths(config *MassdriverYAML) []string { +func referencedPaths(config *resourcetype.MassdriverYAML) []string { var refs []string if config.UI != nil { for _, inst := range config.UI.Instructions { @@ -49,7 +50,7 @@ func referencedPaths(config *MassdriverYAML) []string { // It admits the allowlisted top-level files plus the exact instruction and // export template files the massdriver.yaml references (wherever they live in // the directory tree), and silently skips everything else. -func packageKeep(config *MassdriverYAML) func(relPath string) bool { +func packageKeep(config *resourcetype.MassdriverYAML) func(relPath string) bool { referenced := map[string]bool{} for _, p := range referencedPaths(config) { if norm := normalizeRel(p); norm != "" { @@ -67,7 +68,7 @@ func packageKeep(config *MassdriverYAML) func(relPath string) bool { // that are absolute, escape the directory, or don't exist would be dropped by // the packager and produce a silently incomplete artifact, so they're rejected // up front. -func validateReferencedFiles(config *MassdriverYAML, srcDir string) error { +func validateReferencedFiles(config *resourcetype.MassdriverYAML, srcDir string) error { for _, ref := range referencedPaths(config) { if ref == "" { continue @@ -103,17 +104,17 @@ func normalizeRel(p string) string { return cleaned } -// Publish validates a resource type located at path and pushes it to its OCI +// RunPublish validates a resource type located at path and pushes it to its OCI // repository. path may be a directory containing a massdriver.yaml, or the // massdriver.yaml itself. It returns the resource type name and the published // version. -func Publish(ctx context.Context, mdClient *massdriver.Client, path string) (string, string, error) { +func RunPublish(ctx context.Context, mdClient *massdriver.Client, path string) (string, string, error) { mdYamlPath, srcDir, resolveErr := resolvePublishPath(path) if resolveErr != nil { return "", "", resolveErr } - config, configErr := ReadConfig(mdYamlPath) + config, configErr := resourcetype.ReadConfig(mdYamlPath) if configErr != nil { return "", "", fmt.Errorf("failed to read massdriver.yaml: %w", configErr) } @@ -150,7 +151,7 @@ func Publish(ctx context.Context, mdClient *massdriver.Client, path string) (str Repo: repo, } - if _, packageErr := publisher.Package(ctx, srcDir, config.Version, ArtifactType, packageKeep(config)); packageErr != nil { + if _, packageErr := publisher.Package(ctx, srcDir, config.Version, resourcetype.ArtifactType, packageKeep(config)); packageErr != nil { return "", "", fmt.Errorf("packaging resource type: %w", packageErr) } @@ -193,7 +194,7 @@ func resolvePublishPath(path string) (mdYamlPath string, srcDir string, err erro // validateSchema builds and dereferences the resource type, then validates it // against the resource type schema and the JSON Schema meta-schema. func validateSchema(ctx context.Context, mdClient *massdriver.Client, mdYamlPath string) error { - rt, readErr := Read(ctx, mdClient, mdYamlPath) + rt, readErr := resourcetype.Read(ctx, mdClient, mdYamlPath) if readErr != nil { return fmt.Errorf("failed to read resource type: %w", readErr) } diff --git a/internal/resourcetype/publish_test.go b/internal/commands/resourcetype/publish_test.go similarity index 71% rename from internal/resourcetype/publish_test.go rename to internal/commands/resourcetype/publish_test.go index a9ac7efc..2016fb9d 100644 --- a/internal/resourcetype/publish_test.go +++ b/internal/commands/resourcetype/publish_test.go @@ -6,14 +6,14 @@ import ( "strings" "testing" - "github.com/massdriver-cloud/mass/internal/resourcetype" + cmdresourcetype "github.com/massdriver-cloud/mass/internal/commands/resourcetype" ) -// TestPublishValidation covers the local validation Publish performs before it -// touches the OCI registry: rejecting raw schema files (pointing at convert) -// and requiring name/version in the massdriver.yaml. These paths short-circuit -// before the massdriver client is used, so a nil client is fine. -func TestPublishValidation(t *testing.T) { +// TestRunPublishValidation covers the local validation RunPublish performs +// before it touches the OCI registry: rejecting raw schema files (pointing at +// convert) and requiring name/version in the massdriver.yaml. These paths +// short-circuit before the massdriver client is used, so a nil client is fine. +func TestRunPublishValidation(t *testing.T) { dir := t.TempDir() rawJSON := filepath.Join(dir, "schema.json") @@ -43,7 +43,7 @@ func TestPublishValidation(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - _, _, err := resourcetype.Publish(t.Context(), nil, tc.path) + _, _, err := cmdresourcetype.RunPublish(t.Context(), nil, tc.path) if err == nil { t.Fatalf("expected an error, got nil") } diff --git a/internal/resourcetype/pull.go b/internal/commands/resourcetype/pull.go similarity index 90% rename from internal/resourcetype/pull.go rename to internal/commands/resourcetype/pull.go index 4c86eb13..f819c29c 100644 --- a/internal/resourcetype/pull.go +++ b/internal/commands/resourcetype/pull.go @@ -9,10 +9,10 @@ import ( "oras.land/oras-go/v2/content/file" ) -// Pull downloads a resource type from its OCI repository into directory, +// RunPull downloads a resource type from its OCI repository into directory, // resolving version to a concrete tag. It returns the resolved tag and the // pulled manifest digest. -func Pull(ctx context.Context, mdClient *massdriver.Client, name, version, directory string) (string, string, error) { +func RunPull(ctx context.Context, mdClient *massdriver.Client, name, version, directory string) (string, string, error) { repo, repoErr := mdClient.OciRepos.Target(name) if repoErr != nil { return "", "", repoErr diff --git a/internal/commands/resourcetype/testdata/simple-resource.json b/internal/commands/resourcetype/testdata/simple-resource.json new file mode 100644 index 00000000..b0837dec --- /dev/null +++ b/internal/commands/resourcetype/testdata/simple-resource.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$md": { + "name": "foo" + }, + "type": "object", + "title": "Test Resource Type", + "properties": { + "foo": { + "type": "object" + }, + "bar": { + "type": "object" + } + } +} diff --git a/internal/resourcetype/build.go b/internal/resourcetype/build.go index ffd29432..0ab23e0f 100644 --- a/internal/resourcetype/build.go +++ b/internal/resourcetype/build.go @@ -9,6 +9,9 @@ import ( "gopkg.in/yaml.v3" ) +// ArtifactType is the OCI artifact-type media type for resource types. +const ArtifactType = "application/vnd.massdriver.resource-type.v1+json" + // MassdriverYAML represents the structure of a massdriver.yaml resource type file. // This is an experimental format that provides a more ergonomic authoring experience. type MassdriverYAML struct { From 2e3698fc984937fd1b66b46eb75d88335fdbe1d7 Mon Sep 17 00:00:00 2001 From: chrisghill Date: Wed, 12 Aug 2026 17:08:59 -0600 Subject: [PATCH 06/19] docs --- docs/generated/mass_resource-type_publish.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/generated/mass_resource-type_publish.md b/docs/generated/mass_resource-type_publish.md index 753b7bfe..aa9e88e1 100644 --- a/docs/generated/mass_resource-type_publish.md +++ b/docs/generated/mass_resource-type_publish.md @@ -29,8 +29,8 @@ mass resource-type publish [path] `path` is a directory containing a `massdriver.yaml` (defaults to the current directory). Only `massdriver.yaml`, `readme`, `changelog`, icon files, and the -`instructions/` and `exports/` directories referenced by the `massdriver.yaml` -are included in the published artifact. +instruction/export template files referenced by the `massdriver.yaml` are +included in the published artifact. ## Examples From 5fb5502af6046c6b1ae799c059d675557dd6557b Mon Sep 17 00:00:00 2001 From: chrisghill Date: Fri, 14 Aug 2026 17:18:28 -0600 Subject: [PATCH 07/19] disable validation on resource types --- .golangci.yaml | 4 --- internal/commands/resourcetype/publish.go | 34 +++++++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 7027461c..cf8a3043 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -416,10 +416,6 @@ linters: # Allow unused params at the cobra command level - linters: [revive] text: "unused-parameter: parameter ('cmd'|'args') seems to be unused, consider removing or renaming it as _" - # 'api' is a domain-appropriate package name for this layer; revive flags it as "meaningless" - - linters: [revive] - text: "var-naming: avoid meaningless package names" - path: "internal/api/" - path: "_test\\.go" linters: - revive diff --git a/internal/commands/resourcetype/publish.go b/internal/commands/resourcetype/publish.go index 4f13cf2f..d2c93526 100644 --- a/internal/commands/resourcetype/publish.go +++ b/internal/commands/resourcetype/publish.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" "github.com/massdriver-cloud/mass/internal/jsonschema" @@ -18,17 +19,20 @@ import ( "oras.land/oras-go/v2/content/memory" ) -// allowedFiles is the subset of top-level files (matched case-insensitively by -// name) that may be packaged into a resource type artifact. Everything else at -// the top level is silently skipped. -var allowedFiles = map[string]bool{ - "massdriver.yaml": true, - "readme.md": true, - "changelog.md": true, - "icon.svg": true, - "icon.png": true, - "icon.jpg": true, - "icon.jpeg": true, +// allowedFiles is the exact set of top-level files that may be packaged into a +// resource type artifact. readme/changelog are listed in both their +// conventional uppercase and lowercase forms; everything else at the top level +// is silently skipped. +var allowedFiles = []string{ + "massdriver.yaml", + "README.md", + "readme.md", + "CHANGELOG.md", + "changelog.md", + "icon.svg", + "icon.png", + "icon.jpg", + "icon.jpeg", } // referencedPaths returns the raw instruction and export template file @@ -59,7 +63,7 @@ func packageKeep(config *resourcetype.MassdriverYAML) func(relPath string) bool } return func(relPath string) bool { - return referenced[relPath] || allowedFiles[strings.ToLower(relPath)] + return referenced[relPath] || slices.Contains(allowedFiles, relPath) } } @@ -137,9 +141,9 @@ func RunPublish(ctx context.Context, mdClient *massdriver.Client, path string) ( return "", "", versionErr } - if validateErr := validateSchema(ctx, mdClient, mdYamlPath); validateErr != nil { - return "", "", validateErr - } + // if validateErr := validateSchema(ctx, mdClient, mdYamlPath); validateErr != nil { + // return "", "", validateErr + // } repo, repoErr := mdClient.OciRepos.Target(config.Name) if repoErr != nil { From 77da2b35f809f1e8eb676a51635b1313a458b5bb Mon Sep 17 00:00:00 2001 From: chrisghill Date: Mon, 17 Aug 2026 16:13:57 -0600 Subject: [PATCH 08/19] re-enable schema validation on resource types --- internal/commands/resourcetype/publish.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/commands/resourcetype/publish.go b/internal/commands/resourcetype/publish.go index d2c93526..a517d777 100644 --- a/internal/commands/resourcetype/publish.go +++ b/internal/commands/resourcetype/publish.go @@ -141,9 +141,9 @@ func RunPublish(ctx context.Context, mdClient *massdriver.Client, path string) ( return "", "", versionErr } - // if validateErr := validateSchema(ctx, mdClient, mdYamlPath); validateErr != nil { - // return "", "", validateErr - // } + if validateErr := validateSchema(ctx, mdClient, mdYamlPath); validateErr != nil { + return "", "", validateErr + } repo, repoErr := mdClient.OciRepos.Target(config.Name) if repoErr != nil { From d4e992146b940a782f94b7cf70d190a0075ad88d Mon Sep 17 00:00:00 2001 From: chrisghill Date: Tue, 25 Aug 2026 17:46:43 -0600 Subject: [PATCH 09/19] swap to new resources and dependencies fields --- cmd/bundle.go | 14 +- internal/bundle/build.go | 9 +- internal/bundle/build_test.go | 152 ---------------------- internal/bundle/bundle.go | 144 ++++++++++++++++---- internal/bundle/bundle_test.go | 151 +++++++++++++++++++++ internal/bundle/combine.go | 4 +- internal/bundle/combine_test.go | 3 + internal/bundle/dereference.go | 9 +- internal/bundle/lint.go | 4 +- internal/bundle/lint_test.go | 6 + internal/bundle/publish.go | 4 - internal/bundle/publish_test.go | 12 +- internal/bundle/write_schemas.go | 91 ------------- internal/commands/bundle/build.go | 29 ++++- internal/commands/bundle/build_test.go | 47 +++++++ internal/commands/bundle/lint.go | 9 +- internal/resourcetype/dereference.go | 7 +- internal/resourcetype/dereference_test.go | 35 +++++ 18 files changed, 418 insertions(+), 312 deletions(-) create mode 100644 internal/bundle/bundle_test.go delete mode 100644 internal/bundle/write_schemas.go create mode 100644 internal/commands/bundle/build_test.go diff --git a/cmd/bundle.go b/cmd/bundle.go index 91a7a6fa..a9d07a8c 100644 --- a/cmd/bundle.go +++ b/cmd/bundle.go @@ -397,12 +397,17 @@ func runBundleLint(cmd *cobra.Command, args []string) error { return fmt.Errorf("error initializing massdriver client: %w", err) } + // Schema validation runs before dereferencing, which assumes a valid bundle. + if err = cmdbundle.ValidateSchema(unmarshalledBundle, mdClient.Config().URL); err != nil { + return err + } + err = unmarshalledBundle.DereferenceSchemas(bundleDirectory, resourcetype.NewMassdriverResolver(mdClient)) if err != nil { return err } - results := cmdbundle.RunLint(unmarshalledBundle, mdClient) + results := cmdbundle.RunLint(unmarshalledBundle) switch { case results.HasErrors(): @@ -452,13 +457,18 @@ func runBundlePublish(cmd *cobra.Command, args []string) error { return fmt.Errorf("error initializing massdriver client: %w", err) } + // Schema validation runs before Build, which dereferences and assumes a valid bundle. + if err = cmdbundle.ValidateSchema(unmarshalledBundle, mdClient.Config().URL); err != nil { + return err + } + err = unmarshalledBundle.Build(bundleDirectory, resourcetype.NewMassdriverResolver(mdClient)) if err != nil { return err } if !skipLint { - results := cmdbundle.RunLint(unmarshalledBundle, mdClient) + results := cmdbundle.RunLint(unmarshalledBundle) switch { case results.HasErrors(): diff --git a/internal/bundle/build.go b/internal/bundle/build.go index 8f9bc36d..cf5b5aba 100644 --- a/internal/bundle/build.go +++ b/internal/bundle/build.go @@ -7,19 +7,14 @@ import ( "github.com/massdriver-cloud/mass/internal/provisioners" ) -// Build dereferences schemas (using resolver for massdriver $refs), writes -// them to disk, and exports provisioner inputs for all steps. +// Build dereferences the params and connections schemas (using resolver for +// massdriver $refs) and exports provisioner inputs for all steps. func (b *Bundle) Build(buildPath string, resolver SchemaResolver) error { err := b.DereferenceSchemas(buildPath, resolver) if err != nil { return err } - err = b.WriteSchemas(buildPath) - if err != nil { - return err - } - combined := b.CombineParamsConnsMetadata() for _, step := range b.Steps { prov := provisioners.NewProvisioner(step.Provisioner) diff --git a/internal/bundle/build_test.go b/internal/bundle/build_test.go index 95f1e32d..fa572e68 100644 --- a/internal/bundle/build_test.go +++ b/internal/bundle/build_test.go @@ -32,127 +32,6 @@ var draftNodeSchema = map[string]any{ }, } -var expectedSchemaContents = map[string][]byte{ - "schema-ui.json": []byte(`{ - "ui:order": [ - "resource_name", - "*" - ] -} -`), - "schema-params.json": []byte(`{ - "$id": "https://schemas.massdriver.cloud/schemas/bundles/draft-node/schema-params.json", - "$schema": "http://json-schema.org/draft-07/schema", - "description": "A resource that can be used to visually design architecture without provisioning real infrastructure.", - "examples": [ - { - "__name": "Network", - "resource_type": "Network" - } - ], - "properties": { - "foo": { - "description": "A map of Foos", - "properties": { - "bar": { - "default": 1, - "description": "Testing numbers", - "title": "A whole number", - "type": "integer" - }, - "qux": { - "description": "Testing numbers", - "minimum": 2, - "title": "A whole number that is not required", - "type": "integer" - } - }, - "required": [ - "bar" - ], - "title": "Foo", - "type": "object" - }, - "resource_name": { - "$md.immutable": true, - "description": "An immutable name field", - "title": "Resource Name", - "type": "string" - }, - "resource_type": { - "description": "The type of resource", - "title": "Resource Type", - "type": "string" - } - }, - "required": [ - "resource_type" - ], - "title": "draft-node" -} -`), - "schema-connections.json": []byte(`{ - "$id": "https://schemas.massdriver.cloud/schemas/bundles/draft-node/schema-connections.json", - "$schema": "http://json-schema.org/draft-07/schema", - "description": "A resource that can be used to visually design architecture without provisioning real infrastructure.", - "properties": { - "draft_node_foo": { - "properties": { - "foo": { - "properties": { - "infrastructure": { - "properties": { - "arn": { - "type": "string" - } - }, - "type": "object" - } - }, - "type": "object" - } - }, - "type": "object" - } - }, - "required": [ - "draft_node_foo" - ], - "title": "draft-node" -} -`), - "schema-artifacts.json": []byte(`{ - "$id": "https://schemas.massdriver.cloud/schemas/bundles/draft-node/schema-artifacts.json", - "$schema": "http://json-schema.org/draft-07/schema", - "description": "A resource that can be used to visually design architecture without provisioning real infrastructure.", - "properties": { - "draft_node": { - "properties": { - "foo": { - "properties": { - "infrastructure": { - "properties": { - "arn": { - "type": "string" - } - }, - "type": "object" - } - }, - "type": "object" - } - }, - "type": "object" - } - }, - "required": [ - "draft_node" - ], - "title": "draft-node" -} -`), -} - var expectedTFContent = map[string][]byte{ "_massdriver_variables.tf": []byte(`// This file is auto-generated by massdriver from your massdriver.yaml file. // Any changes made directly to this file will be overwritten on the next build. @@ -213,37 +92,6 @@ func stubResolver(rt map[string]any) bundle.SchemaResolver { } } -func TestBuildSchemas(t *testing.T) { - testDir := t.TempDir() - if err := mockfilesystem.SetupBundle(testDir); err != nil { - t.Fatal(err) - } - - file, err := os.ReadFile(path.Join(testDir, "massdriver.yaml")) - if err != nil { - t.Fatal(err) - } - - unmarshalledBundle := &bundle.Bundle{} - if err := yaml.Unmarshal(file, unmarshalledBundle); err != nil { - t.Fatal(err) - } - - if err := unmarshalledBundle.Build(testDir, stubResolver(draftNodeSchema)); err != nil { - t.Fatal(err) - } - - for fileName, expectedFileContent := range expectedSchemaContents { - gotContent, readFileErr := os.ReadFile(path.Join(testDir, fileName)) - if readFileErr != nil { - t.Fatal(readFileErr) - } - if string(gotContent) != string(expectedFileContent) { - t.Errorf("Expected file content for %s to be %s but got %s", fileName, string(expectedFileContent), string(gotContent)) - } - } -} - func TestBuildTFVars(t *testing.T) { testDir := t.TempDir() if err := mockfilesystem.SetupBundle(testDir); err != nil { diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index 81e07b0e..d1ac7261 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -3,6 +3,7 @@ package bundle import ( "embed" "encoding/json" + "errors" "fmt" "path/filepath" "regexp" @@ -33,6 +34,35 @@ type Step struct { Config map[string]any `json:"config,omitempty" yaml:"config,omitempty" mapstructure:"config"` } +// AppSpec defines the application-specific configuration for environment variables, policies, and secrets. +type AppSpec struct { + Envs map[string]string `json:"envs" yaml:"envs" mapstructure:"envs"` + Policies []string `json:"policies" yaml:"policies" mapstructure:"policies"` + Secrets map[string]Secret `json:"secrets" yaml:"secrets" mapstructure:"secrets"` +} + +// Secret describes a secret that the bundle expects to be injected at runtime. +type Secret struct { + Required bool `json:"required,omitempty" yaml:"required,omitempty" mapstructure:"required"` + JSON bool `json:"json,omitempty" yaml:"json,omitempty" mapstructure:"json"` + Title string `json:"title,omitempty" yaml:"title,omitempty" mapstructure:"title"` + Description string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description"` +} + +// Resource is one entry in a bundle's `resources` block — a resource the bundle +// produces. +type Resource struct { + ResourceType string `json:"resource_type,omitempty" yaml:"resource_type,omitempty" mapstructure:"resource_type"` + Required *bool `json:"required,omitempty" yaml:"required,omitempty" mapstructure:"required"` +} + +// Dependency is one entry in a bundle's `dependencies` block — a resource the +// bundle depends on. +type Dependency struct { + ResourceType string `json:"resource_type,omitempty" yaml:"resource_type,omitempty" mapstructure:"resource_type"` + Required *bool `json:"required,omitempty" yaml:"required,omitempty" mapstructure:"required"` +} + // Bundle represents a Massdriver bundle definition parsed from massdriver.yaml. type Bundle struct { Name string `json:"name,omitempty" yaml:"name,omitempty" mapstructure:"name"` @@ -47,21 +77,17 @@ type Bundle struct { Connections map[string]any `json:"connections,omitempty" yaml:"connections,omitempty" mapstructure:"connections"` UI map[string]any `json:"ui,omitempty" yaml:"ui,omitempty" mapstructure:"ui"` AppSpec *AppSpec `json:"app,omitempty" yaml:"app,omitempty" mapstructure:"app"` -} -// AppSpec defines the application-specific configuration for environment variables, policies, and secrets. -type AppSpec struct { - Envs map[string]string `json:"envs" yaml:"envs" mapstructure:"envs"` - Policies []string `json:"policies" yaml:"policies" mapstructure:"policies"` - Secrets map[string]Secret `json:"secrets" yaml:"secrets" mapstructure:"secrets"` -} + // Resources and Dependencies are the current input terms. Artifacts and + // Connections are their deprecated predecessors, accepted only at version + // 0.0.0. + Resources map[string]Resource `json:"resources,omitempty" yaml:"resources,omitempty" mapstructure:"resources"` + Dependencies map[string]Dependency `json:"dependencies,omitempty" yaml:"dependencies,omitempty" mapstructure:"dependencies"` -// Secret describes a secret that the bundle expects to be injected at runtime. -type Secret struct { - Required bool `json:"required,omitempty" yaml:"required,omitempty" mapstructure:"required"` - JSON bool `json:"json,omitempty" yaml:"json,omitempty" mapstructure:"json"` - Title string `json:"title,omitempty" yaml:"title,omitempty" mapstructure:"title"` - Description string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description"` + // dependencySchema is the canonical JSON-schema form of the bundle's + // dependencies (from Dependencies or the legacy Connections block), hydrated + // on demand and dereferenced in place by DereferenceSchemas. + dependencySchema map[string]any } // Unmarshal reads and parses the massdriver.yaml file from the given directory into a Bundle. @@ -87,21 +113,8 @@ func Unmarshal(readDirectory string) (*Bundle, error) { applyAppBlockDefaults(unmarshalledBundle) applyStepDefaults(unmarshalledBundle) - // This looks weird but we have to be careful we don't overwrite things that do exist in the bundle file - if unmarshalledBundle.Connections == nil { - unmarshalledBundle.Connections = make(map[string]any) - } - - if unmarshalledBundle.Connections["properties"] == nil { - unmarshalledBundle.Connections["properties"] = make(map[string]any) - } - - if unmarshalledBundle.Artifacts == nil { - unmarshalledBundle.Artifacts = make(map[string]any) - } - - if unmarshalledBundle.Artifacts["properties"] == nil { - unmarshalledBundle.Artifacts["properties"] = make(map[string]any) + if err := unmarshalledBundle.normalizeInputs(); err != nil { + return nil, err } if transformationErr := ApplyTransformations(unmarshalledBundle.Params, paramsTransformations); transformationErr != nil { @@ -150,3 +163,78 @@ func parseMetadataSchema() map[string]any { return metadata } + +// normalizeInputs validates the `resources`/`dependencies` blocks and enforces +// the rules around the legacy `artifacts`/`connections` terms: the two forms of a +// slot are mutually exclusive, and the legacy terms are only usable at version +// 0.0.0 (warn there, error at any real version). It does not write into the +// legacy fields — the dependency schema is hydrated separately. +func (b *Bundle) normalizeInputs() error { + hasArtifacts := b.Artifacts != nil + hasConnections := b.Connections != nil + hasResources := b.Resources != nil + hasDependencies := b.Dependencies != nil + + if hasConnections && hasDependencies { + return errors.New("cannot set both 'connections' and 'dependencies'; use 'dependencies'") + } + if hasArtifacts && hasResources { + return errors.New("cannot set both 'artifacts' and 'resources'; use 'resources'") + } + if hasConnections { + if err := b.checkDeprecatedTerm("connections", "dependencies"); err != nil { + return err + } + } + if hasArtifacts { + if err := b.checkDeprecatedTerm("artifacts", "resources"); err != nil { + return err + } + } + + b.hydrateDependencySchema() + return nil +} + +// checkDeprecatedTerm enforces that a legacy term (artifacts/connections) is only +// usable at version 0.0.0: it warns at 0.0.0 and errors at any real version. +func (b *Bundle) checkDeprecatedTerm(oldTerm, newTerm string) error { + if b.Version != "0.0.0" { + return fmt.Errorf("the '%s' field is deprecated and doesn't support versioning; migrate to '%s' to publish version %s", oldTerm, newTerm, b.Version) + } + fmt.Println(prettylogs.Orange(fmt.Sprintf("Warning: the '%s' field is deprecated; migrate to '%s'. The legacy term does not support versioned resource types", oldTerm, newTerm))) + return nil +} + +// hydrateDependencySchema builds dependencySchema — the canonical JSON-schema map +// ({properties: {name: {$ref}}, required: [...]}) that downstream code (schema +// dereferencing, provisioner input generation, lint) reads for dependencies. It +// is sourced from `dependencies` (new) or the legacy `connections` block. +func (b *Bundle) hydrateDependencySchema() { + switch { + case len(b.Dependencies) > 0: + b.dependencySchema = dependenciesToSchema(b.Dependencies) + case b.Connections != nil: + if _, ok := b.Connections["properties"].(map[string]any); !ok { + b.Connections["properties"] = map[string]any{} + } + b.dependencySchema = b.Connections + default: + b.dependencySchema = map[string]any{"properties": map[string]any{}} + } +} + +// dependenciesToSchema converts a `dependencies` map into the canonical JSON +// schema. Per-entry validation (resource_type/required presence) is handled by +// bundle schema validation, before dereferencing. +func dependenciesToSchema(deps map[string]Dependency) map[string]any { + properties := map[string]any{} + required := []any{} + for name, dep := range deps { + properties[name] = map[string]any{"$ref": dep.ResourceType} + if dep.Required != nil && *dep.Required { + required = append(required, name) + } + } + return map[string]any{"properties": properties, "required": required} +} diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go new file mode 100644 index 00000000..ea2397b5 --- /dev/null +++ b/internal/bundle/bundle_test.go @@ -0,0 +1,151 @@ +package bundle //nolint:testpackage // exercises unexported normalizeInputs/dependencySchema + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func boolPtr(b bool) *bool { return &b } + +// TestUnmarshalDependencyResourceVariants covers the three ways `dependencies` +// and `resources` can be "empty": missing entirely, present-but-null, and an +// empty object. All must unmarshal cleanly to an empty dependency schema. +func TestUnmarshalDependencyResourceVariants(t *testing.T) { + const base = "name: example\ndescription: a bundle\nversion: 1.0.0\nsteps:\n - path: src\n provisioner: terraform\nparams:\n properties: {}\nui: {}\n" + cases := map[string]string{ + "missing": base, + "null": base + "dependencies:\nresources:\n", + "empty": base + "dependencies: {}\nresources: {}\n", + } + + for name, contents := range cases { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "massdriver.yaml"), []byte(contents), 0600); err != nil { + t.Fatal(err) + } + + b, err := Unmarshal(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + props, ok := b.dependencySchema["properties"].(map[string]any) + if !ok { + t.Fatalf("dependencySchema has no properties map: %#v", b.dependencySchema) + } + if len(props) != 0 { + t.Errorf("expected empty dependency schema, got: %#v", props) + } + }) + } +} + +func TestDependenciesToSchema(t *testing.T) { + deps := map[string]Dependency{ + "network": {ResourceType: "aws-vpc@1.2.3", Required: boolPtr(true)}, + "database": {ResourceType: "postgres@2.3.4", Required: boolPtr(false)}, + } + + got := dependenciesToSchema(deps) + + want := map[string]any{ + "properties": map[string]any{ + "network": map[string]any{"$ref": "aws-vpc@1.2.3"}, + "database": map[string]any{"$ref": "postgres@2.3.4"}, + }, + "required": []any{"network"}, // only required==true, sorted + } + if !reflect.DeepEqual(got, want) { + t.Errorf("got %#v, want %#v", got, want) + } +} + +func TestNormalizeInputs(t *testing.T) { + t.Run("dependencies hydrate dependencySchema without touching Connections", func(t *testing.T) { + b := &Bundle{ + Version: "1.0.0", + Dependencies: map[string]Dependency{"network": {ResourceType: "aws-vpc@1.2.3", Required: boolPtr(true)}}, + } + if err := b.normalizeInputs(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if b.Connections != nil { + t.Errorf("Connections should remain untouched, got: %#v", b.Connections) + } + props, ok := b.dependencySchema["properties"].(map[string]any) + if !ok || props["network"] == nil { + t.Fatalf("dependencySchema not hydrated from dependencies: %#v", b.dependencySchema) + } + }) + + t.Run("resources stay first-class without touching Artifacts", func(t *testing.T) { + b := &Bundle{ + Version: "1.0.0", + Resources: map[string]Resource{"bucket": {ResourceType: "aws-s3@1.0.0", Required: boolPtr(true)}}, + } + if err := b.normalizeInputs(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if b.Artifacts != nil { + t.Errorf("Artifacts should remain untouched, got: %#v", b.Artifacts) + } + if b.Resources == nil { + t.Error("Resources should remain the first-class field") + } + }) + + t.Run("legacy connections hydrate dependencySchema at 0.0.0", func(t *testing.T) { + b := &Bundle{Version: "0.0.0", Connections: map[string]any{"properties": map[string]any{"legacy": map[string]any{"$ref": "aws-vpc"}}}} + if err := b.normalizeInputs(); err != nil { + t.Fatalf("legacy term should be allowed at 0.0.0, got: %v", err) + } + props, ok := b.dependencySchema["properties"].(map[string]any) + if !ok || props["legacy"] == nil { + t.Fatalf("dependencySchema not hydrated from legacy connections: %#v", b.dependencySchema) + } + }) + + t.Run("legacy connections at a real version are rejected", func(t *testing.T) { + b := &Bundle{Version: "1.0.0", Connections: map[string]any{"properties": map[string]any{}}} + err := b.normalizeInputs() + if err == nil || !strings.Contains(err.Error(), "deprecated") { + t.Fatalf("want deprecation error at real version, got: %v", err) + } + }) + + t.Run("legacy artifacts at a real version are rejected", func(t *testing.T) { + b := &Bundle{Version: "2.1.0", Artifacts: map[string]any{"properties": map[string]any{}}} + err := b.normalizeInputs() + if err == nil || !strings.Contains(err.Error(), "deprecated") { + t.Fatalf("want deprecation error at real version, got: %v", err) + } + }) + + t.Run("both connections and dependencies is an error", func(t *testing.T) { + b := &Bundle{ + Version: "0.0.0", + Connections: map[string]any{}, + Dependencies: map[string]Dependency{"network": {ResourceType: "aws-vpc", Required: boolPtr(true)}}, + } + err := b.normalizeInputs() + if err == nil || !strings.Contains(err.Error(), "both") { + t.Fatalf("want both-set error, got: %v", err) + } + }) + + t.Run("both artifacts and resources is an error", func(t *testing.T) { + b := &Bundle{ + Version: "0.0.0", + Artifacts: map[string]any{}, + Resources: map[string]Resource{"bucket": {ResourceType: "aws-s3", Required: boolPtr(true)}}, + } + err := b.normalizeInputs() + if err == nil || !strings.Contains(err.Error(), "both") { + t.Fatalf("want both-set error, got: %v", err) + } + }) +} diff --git a/internal/bundle/combine.go b/internal/bundle/combine.go index 4318bb6b..118d24d2 100644 --- a/internal/bundle/combine.go +++ b/internal/bundle/combine.go @@ -4,14 +4,14 @@ import ( "maps" ) -// CombineParamsConnsMetadata merges the bundle's params, connections, and metadata schemas into one map. +// CombineParamsConnsMetadata merges the bundle's params, dependencies, and metadata schemas into one map. func (b *Bundle) CombineParamsConnsMetadata() map[string]any { combined := map[string]any{ "properties": map[string]any{}, "required": []any{}, } - for _, sch := range []map[string]any{b.Params, b.Connections, MetadataSchema} { + for _, sch := range []map[string]any{b.Params, b.dependencySchema, MetadataSchema} { if _, exists := sch["properties"]; exists { combinedProps, ok1 := combined["properties"].(map[string]any) schProps, ok2 := sch["properties"].(map[string]any) diff --git a/internal/bundle/combine_test.go b/internal/bundle/combine_test.go index 215aee2c..92a8040f 100644 --- a/internal/bundle/combine_test.go +++ b/internal/bundle/combine_test.go @@ -95,6 +95,9 @@ func TestCombineParamsConnsMetadata(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + if err := tc.bundle.DereferenceSchemas(".", stubResolver(nil)); err != nil { + t.Fatal(err) + } got := tc.bundle.CombineParamsConnsMetadata() if !reflect.DeepEqual(got, tc.want) { diff --git a/internal/bundle/dereference.go b/internal/bundle/dereference.go index 2df59c61..86d50e51 100644 --- a/internal/bundle/dereference.go +++ b/internal/bundle/dereference.go @@ -19,19 +19,16 @@ type SchemaResolver func(ctx context.Context, name string) (map[string]any, erro // tests. func (b *Bundle) DereferenceSchemas(path string, resolver SchemaResolver) error { cwd := filepath.Dir(path) + b.hydrateDependencySchema() - // The stripID is a hack to get around the issue of the UI choking if the params schema has 2 or more of the same $id in it. - // We need the "$id" in artifacts and connections, but we need to strip it out of params and ui schemas, hence the conditional. - // This logic should be removed when we have a better solution for this in the UI/API - probably after resource types are in OCI + // stripID drops "$id" from the params schema; dependencies keep it. tasks := []struct { schema *map[string]any label string stripID bool }{ - {schema: &b.Artifacts, label: "artifacts", stripID: false}, {schema: &b.Params, label: "params", stripID: true}, - {schema: &b.Connections, label: "connections", stripID: false}, - {schema: &b.UI, label: "ui", stripID: true}, + {schema: &b.dependencySchema, label: "dependencies", stripID: false}, } for _, task := range tasks { diff --git a/internal/bundle/lint.go b/internal/bundle/lint.go index e71b615f..0a42a081 100644 --- a/internal/bundle/lint.go +++ b/internal/bundle/lint.go @@ -156,8 +156,8 @@ func (b *Bundle) LintParamsConnectionsNameCollision() LintResult { if b.Params != nil { if params, ok := b.Params["properties"]; ok { - if b.Connections != nil { - if connections, connectionsOk := b.Connections["properties"]; connectionsOk { + if b.dependencySchema != nil { + if connections, connectionsOk := b.dependencySchema["properties"]; connectionsOk { paramsMap, paramsMapOk := params.(map[string]any) connectionsMap, connectionsMapOk := connections.(map[string]any) if paramsMapOk && connectionsMapOk { diff --git a/internal/bundle/lint_test.go b/internal/bundle/lint_test.go index cfdb4b7d..a91ba8ea 100644 --- a/internal/bundle/lint_test.go +++ b/internal/bundle/lint_test.go @@ -113,6 +113,9 @@ func TestLintParamsConnectionsNameCollision(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + if err := tc.bun.DereferenceSchemas(".", stubResolver(nil)); err != nil { + t.Fatal(err) + } got := tc.bun.LintParamsConnectionsNameCollision() assert.ElementsMatch(t, tc.want.Issues, got.Issues) @@ -240,6 +243,9 @@ func TestLintInputsMatchProvisioner(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + if err := tc.bun.DereferenceSchemas(".", stubResolver(nil)); err != nil { + t.Fatal(err) + } got := tc.bun.LintInputsMatchProvisioner() assert.ElementsMatch(t, tc.want.Issues, got.Issues) diff --git a/internal/bundle/publish.go b/internal/bundle/publish.go index 257497b3..030b6f44 100644 --- a/internal/bundle/publish.go +++ b/internal/bundle/publish.go @@ -36,10 +36,6 @@ func getIgnores(ignorePath string) (*ignore.GitIgnore, error) { "!/readme.md", "!/README.md", "!/CHANGELOG.md", - "!/schema-artifacts.json", - "!/schema-connections.json", - "!/schema-params.json", - "!/schema-ui.json", // Do NOT ignore directories (preserve all dirs) "!/*/", diff --git a/internal/bundle/publish_test.go b/internal/bundle/publish_test.go index 3b25e6e9..e031c9fd 100644 --- a/internal/bundle/publish_test.go +++ b/internal/bundle/publish_test.go @@ -23,14 +23,10 @@ func TestPackageBundle(t *testing.T) { name: "basic bundle", bundleDir: "testdata/publish/simple", expectedLayers: map[string]packageLayer{ - "massdriver.yaml": {MimeType: "application/yaml"}, - "operator.md": {MimeType: "text/markdown"}, - "README.md": {MimeType: "text/markdown"}, - "schema-artifacts.json": {MimeType: "application/json"}, - "schema-connections.json": {MimeType: "application/json"}, - "schema-params.json": {MimeType: "application/json"}, - "schema-ui.json": {MimeType: "application/json"}, - "src/main.tf": {MimeType: "application/hcl"}, + "massdriver.yaml": {MimeType: "application/yaml"}, + "operator.md": {MimeType: "text/markdown"}, + "README.md": {MimeType: "text/markdown"}, + "src/main.tf": {MimeType: "application/hcl"}, }, }, } diff --git a/internal/bundle/write_schemas.go b/internal/bundle/write_schemas.go deleted file mode 100644 index 8d2935bd..00000000 --- a/internal/bundle/write_schemas.go +++ /dev/null @@ -1,91 +0,0 @@ -package bundle - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" -) - -const idURLPattern = "https://schemas.massdriver.cloud/schemas/bundles/%s/schema-%s.json" -const jsonSchemaURL = "http://json-schema.org/draft-07/schema" - -// Schema holds a JSON schema map and its label used when writing schema files. -type Schema struct { - schema map[string]any - label string -} - -// WriteSchemas writes the bundle's artifact, params, connections, and UI schemas to JSON files in buildPath. -func (b *Bundle) WriteSchemas(buildPath string) error { - mkdirErr := os.MkdirAll(buildPath, 0750) - - if mkdirErr != nil { - return mkdirErr - } - - tasks := []Schema{ - {schema: b.Artifacts, label: "artifacts"}, - {schema: b.Params, label: "params"}, - {schema: b.Connections, label: "connections"}, - {schema: b.UI, label: "ui"}, - } - - for _, task := range tasks { - content, err := generateSchema(task.schema, buildMetadata(task.label, *b)) - - if err != nil { - return err - } - - filename := fmt.Sprintf("schema-%s.json", task.label) - - // #nosec G306 - err = os.WriteFile(filepath.Join(buildPath, filename), content, 0644) - - if err != nil { - return err - } - } - - return nil -} - -// generateSchema generates a specific *-schema.json file -func generateSchema(schema map[string]any, metadata map[string]string) ([]byte, error) { - var err error - var mergedSchema = mergeMaps(schema, metadata) - - json, err := json.MarshalIndent(mergedSchema, "", " ") - if err != nil { - return nil, err - } - - return []byte(string(json) + "\n"), nil -} - -func mergeMaps(a map[string]any, b map[string]string) map[string]any { - for k, v := range b { - a[k] = v - } - - return a -} - -func generateIDURL(mdName string, schemaType string) string { - return fmt.Sprintf(idURLPattern, mdName, schemaType) -} - -// buildMetadata returns common metadata fields for each JSON Schema -func buildMetadata(schemaType string, b Bundle) map[string]string { - if schemaType == "ui" { - return make(map[string]string) - } - - return map[string]string{ - "$schema": jsonSchemaURL, - "$id": generateIDURL(b.Name, schemaType), - "title": b.Name, - "description": b.Description, - } -} diff --git a/internal/commands/bundle/build.go b/internal/commands/bundle/build.go index 457831dd..01ad82f8 100644 --- a/internal/commands/bundle/build.go +++ b/internal/commands/bundle/build.go @@ -2,13 +2,40 @@ package bundle import ( + "errors" + "fmt" + "strings" + "github.com/massdriver-cloud/mass/internal/bundle" "github.com/massdriver-cloud/mass/internal/resourcetype" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" ) -// RunBuild builds the bundle at buildPath using the provided bundle and client. +// RunBuild validates the bundle against the Massdriver bundle schema, then builds +// it at buildPath. func RunBuild(buildPath string, b *bundle.Bundle, mdClient *massdriver.Client) error { + if err := ValidateSchema(b, mdClient.Config().URL); err != nil { + return err + } return b.Build(buildPath, resourcetype.NewMassdriverResolver(mdClient)) } + +// ValidateSchema fetches the bundle schema from the Massdriver API and validates +// the bundle against it. It must run before dereferencing, which assumes a +// schema-valid bundle. A fetch failure or any validation error is returned so the +// caller can halt — the API is the authority on the bundle format, and a build +// that can't reach it can't generate correct inputs anyway. +func ValidateSchema(b *bundle.Bundle, serverURL string) error { + result := b.LintSchema(serverURL) + if !result.HasErrors() { + return nil + } + + var sb strings.Builder + sb.WriteString("bundle failed schema validation:") + for _, issue := range result.Errors() { + fmt.Fprintf(&sb, "\n - %s", issue.Message) + } + return errors.New(sb.String()) +} diff --git a/internal/commands/bundle/build_test.go b/internal/commands/bundle/build_test.go new file mode 100644 index 00000000..bbcc80a5 --- /dev/null +++ b/internal/commands/bundle/build_test.go @@ -0,0 +1,47 @@ +package bundle_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + mdbundle "github.com/massdriver-cloud/mass/internal/bundle" + cmdbundle "github.com/massdriver-cloud/mass/internal/commands/bundle" +) + +func TestValidateSchema(t *testing.T) { + schema := `{"type":"object","required":["name","params"],"properties":{"name":{"type":"string"},"params":{"type":"object"}}}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/json-schemas/bundle.json" { + _, _ = w.Write([]byte(schema)) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + params := map[string]any{"properties": map[string]any{}} + + t.Run("valid bundle passes", func(t *testing.T) { + b := &mdbundle.Bundle{Name: "example", Params: params} + if err := cmdbundle.ValidateSchema(b, server.URL); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("schema-invalid bundle is rejected", func(t *testing.T) { + b := &mdbundle.Bundle{Params: params} // missing required name + err := cmdbundle.ValidateSchema(b, server.URL) + if err == nil || !strings.Contains(err.Error(), "schema validation") { + t.Fatalf("want a schema validation error, got: %v", err) + } + }) + + t.Run("unreachable schema is rejected", func(t *testing.T) { + b := &mdbundle.Bundle{Name: "example", Params: params} + if err := cmdbundle.ValidateSchema(b, "http://127.0.0.1:0"); err == nil { + t.Fatal("want an error when the schema cannot be fetched") + } + }) +} diff --git a/internal/commands/bundle/lint.go b/internal/commands/bundle/lint.go index a24f755e..d8fa3b27 100644 --- a/internal/commands/bundle/lint.go +++ b/internal/commands/bundle/lint.go @@ -5,21 +5,14 @@ import ( "github.com/massdriver-cloud/mass/internal/bundle" "github.com/massdriver-cloud/mass/internal/prettylogs" - - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" ) // RunLint runs all lint checks on the bundle and returns the combined result. -func RunLint(b *bundle.Bundle, mdClient *massdriver.Client) bundle.LintResult { +func RunLint(b *bundle.Bundle) bundle.LintResult { fmt.Println("Checking massdriver.yaml for errors...") var allResults bundle.LintResult - // Schema validation - schemaResult := b.LintSchema(mdClient.Config().URL) - allResults.Merge(schemaResult) - printLintResult("Schema validation", schemaResult) - // Parameter and connection collision check collisionResult := b.LintParamsConnectionsNameCollision() allResults.Merge(collisionResult) diff --git a/internal/resourcetype/dereference.go b/internal/resourcetype/dereference.go index 5604d640..46721ca8 100644 --- a/internal/resourcetype/dereference.go +++ b/internal/resourcetype/dereference.go @@ -36,7 +36,12 @@ func NewMassdriverResolver(c *massdriver.Client) func(context.Context, string) ( // relativeFilePathPattern only accepts relative file path prefixes "./" and "../" var relativeFilePathPattern = regexp.MustCompile(`^(\.\/|\.\.\/)`) -var massdriverResourceTypePattern = regexp.MustCompile(`^[a-zA-Z0-9-]+(\/[a-zA-Z0-9-]+)?$`) + +// massdriverResourceTypePattern matches a resource-type ref, optionally +// namespaced (owner/name) and optionally version-pinned. The version accepts +// semver (@1.2.3), channels (@~1, @~1.2), and named releases (@latest, +// @latest+dev). The full string is passed through to the resolver. +var massdriverResourceTypePattern = regexp.MustCompile(`^[a-zA-Z0-9-]+(\/[a-zA-Z0-9-]+)?(@[a-zA-Z0-9._~+-]+)?$`) var httpPattern = regexp.MustCompile(`^(http|https)://`) var fragmentPattern = regexp.MustCompile(`^#`) diff --git a/internal/resourcetype/dereference_test.go b/internal/resourcetype/dereference_test.go index c37dc5bf..932609c1 100644 --- a/internal/resourcetype/dereference_test.go +++ b/internal/resourcetype/dereference_test.go @@ -115,6 +115,41 @@ func TestDereferenceSchema(t *testing.T) { "foo": "bar", }, }, + { + Name: "Dereferences exact-version ref", + Input: jsonDecode(`{"$ref": "massdriver/test-schema@1.2.3"}`), + Expected: map[string]any{ + "foo": "bar", + }, + }, + { + Name: "Dereferences patch-channel ref", + Input: jsonDecode(`{"$ref": "test-schema@~1.2"}`), + Expected: map[string]any{ + "foo": "bar", + }, + }, + { + Name: "Dereferences minor-channel ref", + Input: jsonDecode(`{"$ref": "test-schema@~1"}`), + Expected: map[string]any{ + "foo": "bar", + }, + }, + { + Name: "Dereferences latest ref", + Input: jsonDecode(`{"$ref": "test-schema@latest"}`), + Expected: map[string]any{ + "foo": "bar", + }, + }, + { + Name: "Dereferences latest+dev ref", + Input: jsonDecode(`{"$ref": "test-schema@latest+dev"}`), + Expected: map[string]any{ + "foo": "bar", + }, + }, } // A stub resolver that pretends every massdriver ref points at the same From 3461e53c137ca89b8bc834091b0ed9c84af4cb12 Mon Sep 17 00:00:00 2001 From: chrisghill Date: Tue, 25 Aug 2026 19:01:56 -0600 Subject: [PATCH 10/19] update SDK version --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e78f1cb7..76b2e9ef 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/itchyny/gojq v0.12.16 github.com/manifoldco/promptui v0.9.0 github.com/massdriver-cloud/airlock v0.0.10 - github.com/massdriver-cloud/massdriver-sdk-go v0.2.18 + github.com/massdriver-cloud/massdriver-sdk-go v0.2.19 github.com/mattn/go-runewidth v0.0.24 github.com/opencontainers/image-spec v1.1.1 github.com/osteele/liquid v1.7.0 diff --git a/go.sum b/go.sum index f803c10e..4e35a2ab 100644 --- a/go.sum +++ b/go.sum @@ -141,8 +141,8 @@ github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYt github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/massdriver-cloud/airlock v0.0.10 h1:05wz7kovH09X1VMfHcjLWylYanoLFcxuD0oZi13WG9U= github.com/massdriver-cloud/airlock v0.0.10/go.mod h1:igJm33JvINiUtbyEspUeKUWyWewG+jYyxO1UDHqLp9Q= -github.com/massdriver-cloud/massdriver-sdk-go v0.2.18 h1:Z3j9qjZU2nYuEQ24LRuYLQoPxydVP6NzW6iy/T23xEg= -github.com/massdriver-cloud/massdriver-sdk-go v0.2.18/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.19 h1:4p9+wexriVdfO6yC2bVEOOSohROXMup0ATx4f0tupVY= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.19/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= github.com/massdriver-cloud/terraform-config-inspect v0.0.2 h1:Jc7BrhFHLbK7Epig6ShiEVMzQPPHVIOx0/BatvtEwtY= github.com/massdriver-cloud/terraform-config-inspect v0.0.2/go.mod h1:3AbDpWxIRMdMAg7FDmTJuVBhCGNwdm49cBIOmUHjqRg= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= From aa1748620a6b97f9bb9cd67c9bc5f1c406952dcb Mon Sep 17 00:00:00 2001 From: chrisghill Date: Tue, 25 Aug 2026 23:42:27 -0600 Subject: [PATCH 11/19] fix bugs from testing --- cmd/bundle.go | 10 +++++++--- cmd/resource_type.go | 10 +++++++--- docs/generated/mass_bundle_pull.md | 3 +-- docs/generated/mass_resource-type_pull.md | 10 ++++++---- docs/helpdocs/type/pull.md | 7 +++++-- internal/commands/resourcetype/publish.go | 8 +++++++- internal/commands/resourcetype/publish_test.go | 8 ++------ 7 files changed, 35 insertions(+), 21 deletions(-) diff --git a/cmd/bundle.go b/cmd/bundle.go index a9d07a8c..5153ca17 100644 --- a/cmd/bundle.go +++ b/cmd/bundle.go @@ -143,14 +143,13 @@ func NewCmdBundle() *cobra.Command { //nolint:funlen // cobra command builders a bundleGetCmd.Flags().StringP("output", "o", "text", "Output format (text or json)") bundlePullCmd := &cobra.Command{ - Use: "pull ", + Use: "pull [@]", Short: "Pull bundle from Massdriver to local directory", Args: cobra.ExactArgs(1), RunE: runBundlePull, } bundlePullCmd.Flags().StringP("directory", "d", "", "Directory to output the bundle. Defaults to bundle name.") bundlePullCmd.Flags().BoolP("force", "f", false, "Force pull even if the directory already exists. This will overwrite existing files.") - bundlePullCmd.Flags().StringP("version", "v", "latest", "Bundle version or release channel") bundleTemplateCmd := &cobra.Command{ Use: "template", @@ -492,12 +491,17 @@ func runBundlePull(cmd *cobra.Command, args []string) error { ctx := context.Background() bundleName := args[0] + version := "latest" + if name, ref, found := strings.Cut(bundleName, "@"); found { + bundleName = name + version = ref + } + directory, _ := cmd.Flags().GetString("directory") if directory == "" { directory = bundleName } force, _ := cmd.Flags().GetBool("force") - version, _ := cmd.Flags().GetString("version") cmd.SilenceUsage = true // Check if bundle exists in the specified directory and if so prompt the user diff --git a/cmd/resource_type.go b/cmd/resource_type.go index 7c4a2aa5..3fc5a84f 100644 --- a/cmd/resource_type.go +++ b/cmd/resource_type.go @@ -76,7 +76,7 @@ func NewCmdType() *cobra.Command { } typePullCmd := &cobra.Command{ - Use: "pull ", + Use: "pull [@]", Short: "Pull a resource type from Massdriver to a local directory", Long: helpdocs.MustRender("type/pull"), Args: cobra.ExactArgs(1), @@ -84,7 +84,6 @@ func NewCmdType() *cobra.Command { } typePullCmd.Flags().StringP("directory", "d", "", "Directory to output the resource type. Defaults to the resource type name.") typePullCmd.Flags().BoolP("force", "f", false, "Force pull even if the directory already exists. This will overwrite existing files.") - typePullCmd.Flags().StringP("version", "v", "latest", "Resource type version or release channel") typeDeleteCmd := &cobra.Command{ Use: "delete [resource-type]", @@ -211,12 +210,17 @@ func runTypePull(cmd *cobra.Command, args []string) error { ctx := context.Background() name := args[0] + version := "latest" + if n, ref, found := strings.Cut(name, "@"); found { + name = n + version = ref + } + directory, _ := cmd.Flags().GetString("directory") if directory == "" { directory = name } force, _ := cmd.Flags().GetBool("force") - version, _ := cmd.Flags().GetString("version") cmd.SilenceUsage = true // Warn before overwriting an existing resource type in the target directory. diff --git a/docs/generated/mass_bundle_pull.md b/docs/generated/mass_bundle_pull.md index 06669c64..f8c56ef6 100644 --- a/docs/generated/mass_bundle_pull.md +++ b/docs/generated/mass_bundle_pull.md @@ -9,7 +9,7 @@ sidebar_label: Mass Bundle Pull Pull bundle from Massdriver to local directory ``` -mass bundle pull [flags] +mass bundle pull [@] [flags] ``` ### Options @@ -18,7 +18,6 @@ mass bundle pull [flags] -d, --directory string Directory to output the bundle. Defaults to bundle name. -f, --force Force pull even if the directory already exists. This will overwrite existing files. -h, --help help for pull - -v, --version string Bundle version or release channel (default "latest") ``` ### SEE ALSO diff --git a/docs/generated/mass_resource-type_pull.md b/docs/generated/mass_resource-type_pull.md index dbc28b73..f7b806d3 100644 --- a/docs/generated/mass_resource-type_pull.md +++ b/docs/generated/mass_resource-type_pull.md @@ -18,9 +18,12 @@ directory. ## Usage ```bash -mass resource-type pull [flags] +mass resource-type pull [@] [flags] ``` +The version can be an exact version, a release channel (e.g. `~1.2`), or +`latest`. When omitted, the latest version is pulled. + ## Examples ```bash @@ -28,12 +31,12 @@ mass resource-type pull [flags] mass resource-type pull my-resource-type # Pull a specific version into a specific directory -mass resource-type pull my-resource-type --version 1.2.0 --directory ./out +mass resource-type pull my-resource-type@1.2.0 --directory ./out ``` ``` -mass resource-type pull [flags] +mass resource-type pull [@] [flags] ``` ### Options @@ -42,7 +45,6 @@ mass resource-type pull [flags] -d, --directory string Directory to output the resource type. Defaults to the resource type name. -f, --force Force pull even if the directory already exists. This will overwrite existing files. -h, --help help for pull - -v, --version string Resource type version or release channel (default "latest") ``` ### SEE ALSO diff --git a/docs/helpdocs/type/pull.md b/docs/helpdocs/type/pull.md index 90ffff58..23a63621 100644 --- a/docs/helpdocs/type/pull.md +++ b/docs/helpdocs/type/pull.md @@ -6,9 +6,12 @@ directory. ## Usage ```bash -mass resource-type pull [flags] +mass resource-type pull [@] [flags] ``` +The version can be an exact version, a release channel (e.g. `~1.2`), or +`latest`. When omitted, the latest version is pulled. + ## Examples ```bash @@ -16,5 +19,5 @@ mass resource-type pull [flags] mass resource-type pull my-resource-type # Pull a specific version into a specific directory -mass resource-type pull my-resource-type --version 1.2.0 --directory ./out +mass resource-type pull my-resource-type@1.2.0 --directory ./out ``` diff --git a/internal/commands/resourcetype/publish.go b/internal/commands/resourcetype/publish.go index a517d777..e07489f6 100644 --- a/internal/commands/resourcetype/publish.go +++ b/internal/commands/resourcetype/publish.go @@ -14,6 +14,7 @@ import ( "github.com/massdriver-cloud/mass/internal/jsonschema" "github.com/massdriver-cloud/mass/internal/oci" + "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/mass/internal/resourcetype" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "oras.land/oras-go/v2/content/memory" @@ -126,7 +127,8 @@ func RunPublish(ctx context.Context, mdClient *massdriver.Client, path string) ( return "", "", fmt.Errorf("name is required in %s", mdYamlPath) } if config.Version == "" { - return "", "", fmt.Errorf("version is required in %s", mdYamlPath) + fmt.Println(prettylogs.Orange("Warning: the 'version' field in massdriver.yaml is empty. This disables all versioning capabilities.")) + config.Version = "0.0.0" } // Referenced instruction/export files must live inside the packaged @@ -226,6 +228,10 @@ func validateSchema(ctx context.Context, mdClient *massdriver.Client, mdYamlPath // checkDuplicateVersion fails locally if version has already been published, // matching the immutability the API enforces. func checkDuplicateVersion(ctx context.Context, mdClient *massdriver.Client, name, version string) error { + // 0.0.0 is the unversioned/dev tag — always republishable, matching bundles. + if version == "0.0.0" { + return nil + } repo, err := mdClient.OciRepos.Get(ctx, name) if err != nil { return fmt.Errorf("fetching OCI repo: %w", err) diff --git a/internal/commands/resourcetype/publish_test.go b/internal/commands/resourcetype/publish_test.go index 2016fb9d..bbaa76ae 100644 --- a/internal/commands/resourcetype/publish_test.go +++ b/internal/commands/resourcetype/publish_test.go @@ -11,8 +11,9 @@ import ( // TestRunPublishValidation covers the local validation RunPublish performs // before it touches the OCI registry: rejecting raw schema files (pointing at -// convert) and requiring name/version in the massdriver.yaml. These paths +// convert) and requiring a name in the massdriver.yaml. These paths // short-circuit before the massdriver client is used, so a nil client is fine. +// (A missing version is not an error — it warns and defaults to 0.0.0.) func TestRunPublishValidation(t *testing.T) { dir := t.TempDir() @@ -20,10 +21,6 @@ func TestRunPublishValidation(t *testing.T) { if err := os.WriteFile(rawJSON, []byte("{}"), 0600); err != nil { t.Fatal(err) } - noVersionDir := t.TempDir() - if err := os.WriteFile(filepath.Join(noVersionDir, "massdriver.yaml"), []byte("name: foo\n"), 0600); err != nil { - t.Fatal(err) - } noNameDir := t.TempDir() if err := os.WriteFile(filepath.Join(noNameDir, "massdriver.yaml"), []byte("version: 1.0.0\n"), 0600); err != nil { t.Fatal(err) @@ -37,7 +34,6 @@ func TestRunPublishValidation(t *testing.T) { }{ {name: "raw JSON schema rejected", path: rawJSON, contains: "convert"}, {name: "directory without massdriver.yaml", path: emptyDir, contains: "no massdriver.yaml"}, - {name: "missing version", path: noVersionDir, contains: "version is required"}, {name: "missing name", path: noNameDir, contains: "name is required"}, } From e001f6d72af38c3ee6f3783833072cfd628c4563 Mon Sep 17 00:00:00 2001 From: chrisghill Date: Wed, 26 Aug 2026 00:04:11 -0600 Subject: [PATCH 12/19] use yaml.v3 to preserve integers during conversion --- internal/commands/resourcetype/convert.go | 24 ++-- .../commands/resourcetype/convert_test.go | 103 ++++++++++++++++++ 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/internal/commands/resourcetype/convert.go b/internal/commands/resourcetype/convert.go index f53e1b3c..33d4e052 100644 --- a/internal/commands/resourcetype/convert.go +++ b/internal/commands/resourcetype/convert.go @@ -1,7 +1,6 @@ package resourcetype import ( - "encoding/json" "fmt" "os" "path/filepath" @@ -84,23 +83,24 @@ func RunConvert(schemaPath, outputPath string, force bool) (*ConvertResult, erro } func readRawSchema(path string) (map[string]any, error) { + switch strings.ToLower(filepath.Ext(path)) { + case ".json", ".yaml", ".yml": + default: + return nil, fmt.Errorf("unsupported schema file extension: %s (expected .json, .yaml, or .yml)", filepath.Ext(path)) + } + data, readErr := os.ReadFile(path) if readErr != nil { return nil, fmt.Errorf("failed to read schema: %w", readErr) } + // JSON is valid YAML, so both go through yaml.v3. This preserves integers as + // int; encoding/json would coerce every number to float64, which corrupts + // large integers and re-emits them in scientific notation (e.g. 1000000 -> + // 1e+06) when the schema is marshalled back out. var raw map[string]any - switch strings.ToLower(filepath.Ext(path)) { - case ".json": - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("failed to parse JSON schema: %w", err) - } - case ".yaml", ".yml": - if err := yaml.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("failed to parse YAML schema: %w", err) - } - default: - return nil, fmt.Errorf("unsupported schema file extension: %s (expected .json, .yaml, or .yml)", filepath.Ext(path)) + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse schema: %w", err) } return raw, nil } diff --git a/internal/commands/resourcetype/convert_test.go b/internal/commands/resourcetype/convert_test.go index 55a4c7a3..214fc5e0 100644 --- a/internal/commands/resourcetype/convert_test.go +++ b/internal/commands/resourcetype/convert_test.go @@ -89,6 +89,109 @@ func TestRunConvertDistinctFilesForDuplicateLabels(t *testing.T) { } } +// TestRunConvertRoundTrip converts a realistic raw schema and rebuilds it with +// resourcetype.Build, verifying that instruction/export content is extracted and +// restored and that numeric constraints survive (a regression guard for the +// json-float64 corruption that turned integers into scientific notation). +func TestRunConvertRoundTrip(t *testing.T) { + dir := t.TempDir() + raw := `{ + "$schema": "http://json-schema.org/draft-07/schema", + "$md": { + "name": "roundtrip", + "label": "Round Trip", + "icon": "https://example.com/icon.svg", + "ui": { + "connectionOrientation": "environmentDefault", + "instructions": [ + { "label": "CLI Setup", "content": "step one\nstep two" } + ] + }, + "export": [ + { "downloadButtonText": "Download", "fileFormat": "yaml", "template": "key: {{ .val }}", "templateLang": "liquid" } + ] + }, + "type": "object", + "required": ["token"], + "properties": { + "token": { "type": "string" }, + "count": { "type": "integer", "minimum": 2, "default": 1000000 } + } +}` + schemaPath := filepath.Join(dir, "raw.json") + if err := os.WriteFile(schemaPath, []byte(raw), 0600); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "bundle", "massdriver.yaml") + + if _, err := cmdresourcetype.RunConvert(schemaPath, out, false); err != nil { + t.Fatalf("RunConvert failed: %v", err) + } + + built, err := rtype.Build(out) + if err != nil { + t.Fatalf("rebuilding the converted massdriver.yaml failed: %v", err) + } + + md, ok := built["$md"].(map[string]any) + if !ok { + t.Fatalf("$md missing from rebuilt schema: %#v", built) + } + if md["name"] != "roundtrip" { + t.Errorf("name = %v, want roundtrip", md["name"]) + } + + // Instruction content extracted to a file and restored on rebuild. + ui, _ := md["ui"].(map[string]any) + instructions, _ := ui["instructions"].([]map[string]any) + if len(instructions) != 1 || instructions[0]["content"] != "step one\nstep two" { + t.Errorf("instruction content not restored: %#v", instructions) + } + + // Export template extracted to a file and restored on rebuild. + exports, _ := md["export"].([]map[string]any) + if len(exports) != 1 || exports[0]["template"] != "key: {{ .val }}" { + t.Errorf("export template not restored: %#v", exports) + } + + // Numeric fidelity: the large integer default must round-trip as an int, + // not a float rendered in scientific notation. + props, _ := built["properties"].(map[string]any) + count, _ := props["count"].(map[string]any) + if d, ok := count["default"].(int); !ok || d != 1000000 { + t.Errorf("count.default = %#v (%T), want int 1000000", count["default"], count["default"]) + } +} + +func TestRunConvertYAMLInput(t *testing.T) { + dir := t.TempDir() + raw := "$md:\n name: from-yaml\ntype: object\nproperties:\n token:\n type: string\n" + schemaPath := filepath.Join(dir, "raw.yaml") + if err := os.WriteFile(schemaPath, []byte(raw), 0600); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "massdriver.yaml") + + if _, err := cmdresourcetype.RunConvert(schemaPath, out, false); err != nil { + t.Fatalf("RunConvert failed for YAML input: %v", err) + } + + data, readErr := os.ReadFile(out) + if readErr != nil { + t.Fatal(readErr) + } + var config rtype.MassdriverYAML + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("output is not valid massdriver.yaml: %v", err) + } + if config.Name != "from-yaml" { + t.Errorf("name = %q, want from-yaml", config.Name) + } + if _, ok := config.Schema["properties"]; !ok { + t.Error("schema should retain properties from YAML input") + } +} + func TestRunConvertRefusesToClobber(t *testing.T) { out := filepath.Join(t.TempDir(), "massdriver.yaml") if err := os.WriteFile(out, []byte("existing"), 0600); err != nil { From 77f4de6327204aa06a4c1d95bede57d26a2bffd0 Mon Sep 17 00:00:00 2001 From: chrisghill Date: Wed, 26 Aug 2026 00:18:05 -0600 Subject: [PATCH 13/19] warn on old terms, don't fail --- internal/bundle/bundle.go | 31 ++++++++----------------------- internal/bundle/bundle_test.go | 14 ++++++-------- 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index d1ac7261..100697ee 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -79,8 +79,7 @@ type Bundle struct { AppSpec *AppSpec `json:"app,omitempty" yaml:"app,omitempty" mapstructure:"app"` // Resources and Dependencies are the current input terms. Artifacts and - // Connections are their deprecated predecessors, accepted only at version - // 0.0.0. + // Connections are their deprecated predecessors. Resources map[string]Resource `json:"resources,omitempty" yaml:"resources,omitempty" mapstructure:"resources"` Dependencies map[string]Dependency `json:"dependencies,omitempty" yaml:"dependencies,omitempty" mapstructure:"dependencies"` @@ -164,11 +163,11 @@ func parseMetadataSchema() map[string]any { return metadata } -// normalizeInputs validates the `resources`/`dependencies` blocks and enforces -// the rules around the legacy `artifacts`/`connections` terms: the two forms of a -// slot are mutually exclusive, and the legacy terms are only usable at version -// 0.0.0 (warn there, error at any real version). It does not write into the -// legacy fields — the dependency schema is hydrated separately. +// normalizeInputs reconciles the input blocks: the two forms of a slot +// (`connections`/`dependencies` and `artifacts`/`resources`) are mutually +// exclusive, and using a legacy `connections`/`artifacts` block warns that it's +// deprecated. It does not write into the legacy fields — the dependency schema +// is hydrated separately. func (b *Bundle) normalizeInputs() error { hasArtifacts := b.Artifacts != nil hasConnections := b.Connections != nil @@ -182,30 +181,16 @@ func (b *Bundle) normalizeInputs() error { return errors.New("cannot set both 'artifacts' and 'resources'; use 'resources'") } if hasConnections { - if err := b.checkDeprecatedTerm("connections", "dependencies"); err != nil { - return err - } + fmt.Println(prettylogs.Orange("Warning: the 'connections' field is deprecated; migrate to 'dependencies'. The legacy term does not support versioned resource types")) } if hasArtifacts { - if err := b.checkDeprecatedTerm("artifacts", "resources"); err != nil { - return err - } + fmt.Println(prettylogs.Orange("Warning: the 'artifacts' field is deprecated; migrate to 'resources'. The legacy term does not support versioned resource types")) } b.hydrateDependencySchema() return nil } -// checkDeprecatedTerm enforces that a legacy term (artifacts/connections) is only -// usable at version 0.0.0: it warns at 0.0.0 and errors at any real version. -func (b *Bundle) checkDeprecatedTerm(oldTerm, newTerm string) error { - if b.Version != "0.0.0" { - return fmt.Errorf("the '%s' field is deprecated and doesn't support versioning; migrate to '%s' to publish version %s", oldTerm, newTerm, b.Version) - } - fmt.Println(prettylogs.Orange(fmt.Sprintf("Warning: the '%s' field is deprecated; migrate to '%s'. The legacy term does not support versioned resource types", oldTerm, newTerm))) - return nil -} - // hydrateDependencySchema builds dependencySchema — the canonical JSON-schema map // ({properties: {name: {$ref}}, required: [...]}) that downstream code (schema // dereferencing, provisioner input generation, lint) reads for dependencies. It diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go index ea2397b5..c587c62b 100644 --- a/internal/bundle/bundle_test.go +++ b/internal/bundle/bundle_test.go @@ -109,19 +109,17 @@ func TestNormalizeInputs(t *testing.T) { } }) - t.Run("legacy connections at a real version are rejected", func(t *testing.T) { + t.Run("legacy connections warn but are accepted at any version", func(t *testing.T) { b := &Bundle{Version: "1.0.0", Connections: map[string]any{"properties": map[string]any{}}} - err := b.normalizeInputs() - if err == nil || !strings.Contains(err.Error(), "deprecated") { - t.Fatalf("want deprecation error at real version, got: %v", err) + if err := b.normalizeInputs(); err != nil { + t.Fatalf("legacy connections should be accepted (with a warning), got: %v", err) } }) - t.Run("legacy artifacts at a real version are rejected", func(t *testing.T) { + t.Run("legacy artifacts warn but are accepted at any version", func(t *testing.T) { b := &Bundle{Version: "2.1.0", Artifacts: map[string]any{"properties": map[string]any{}}} - err := b.normalizeInputs() - if err == nil || !strings.Contains(err.Error(), "deprecated") { - t.Fatalf("want deprecation error at real version, got: %v", err) + if err := b.normalizeInputs(); err != nil { + t.Fatalf("legacy artifacts should be accepted (with a warning), got: %v", err) } }) From 1e16b3b4589642119b7c13b65ff05a17d170f728 Mon Sep 17 00:00:00 2001 From: Chris Hill Date: Fri, 28 Aug 2026 18:32:46 -0700 Subject: [PATCH 14/19] re-add support for flag JSON files --- .devcontainer/devcontainer-lock.json | 9 ++ Makefile | 6 +- cmd/resource_type.go | 1 + docs/generated/mass_resource-type_publish.md | 29 +++- docs/helpdocs/type/publish.md | 23 ++- internal/api/api.go | 76 +++++++++ internal/api/resource_type.go | 89 +++++++++++ internal/api/resource_type_test.go | 144 ++++++++++++++++++ internal/commands/resourcetype/publish.go | 101 ++++++++++-- .../commands/resourcetype/publish_test.go | 14 +- .../commands/resourcetype/resolve_test.go | 81 ++++++++++ 11 files changed, 542 insertions(+), 31 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 internal/api/api.go create mode 100644 internal/api/resource_type.go create mode 100644 internal/api/resource_type_test.go create mode 100644 internal/commands/resourcetype/resolve_test.go diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 00000000..49f2c1c8 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/guiyomh/features/golangci-lint:0": { + "version": "0.1.2", + "resolved": "ghcr.io/guiyomh/features/golangci-lint@sha256:6a8e1856aedb04681f0a81b974721ab86140058f4992c02c81c204be755474f2", + "integrity": "sha256:6a8e1856aedb04681f0a81b974721ab86140058f4992c02c81c204be755474f2" + } + } +} diff --git a/Makefile b/Makefile index ffe1d6e4..630901c5 100644 --- a/Makefile +++ b/Makefile @@ -54,15 +54,15 @@ build: .PHONY: build.macos build.macos: bin - @GOOS=darwin GOARCH=arm64 go build -o bin/mass-darwin-arm64 -ldflags=${LD_FLAGS} + @CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/mass-darwin-arm64 -ldflags=${LD_FLAGS} .PHONY: build.linux build.linux: bin - @GOOS=linux GOARCH=amd64 go build -o bin/mass-linux-amd64 -ldflags=${LD_FLAGS} + @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/mass-linux-amd64 -ldflags=${LD_FLAGS} .PHONY: build.windows build.windows: bin - @GOOS=windows GOARCH=amd64 go build -o bin/mass-windows-amd64.exe -ldflags=${LD_FLAGS} + @CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/mass-windows-amd64.exe -ldflags=${LD_FLAGS} .PHONY: install.macos install.macos: build.macos diff --git a/cmd/resource_type.go b/cmd/resource_type.go index 3fc5a84f..205968f7 100644 --- a/cmd/resource_type.go +++ b/cmd/resource_type.go @@ -71,6 +71,7 @@ func NewCmdType() *cobra.Command { Aliases: []string{"push"}, Short: "Publish a resource type to Massdriver", Long: helpdocs.MustRender("type/publish"), + Example: `mass resource-type publish ./my-resource-type`, Args: cobra.MaximumNArgs(1), RunE: runTypePublish, } diff --git a/docs/generated/mass_resource-type_publish.md b/docs/generated/mass_resource-type_publish.md index aa9e88e1..7952d020 100644 --- a/docs/generated/mass_resource-type_publish.md +++ b/docs/generated/mass_resource-type_publish.md @@ -18,9 +18,6 @@ The resource type is authored as a `massdriver.yaml` file, which must include a `version` field. Publishing is immutable: a version that already exists cannot be republished. -Raw JSON schema publishing is no longer supported. If you have a raw JSON schema, -convert it first with `mass resource-type convert`. - ## Usage ```bash @@ -42,11 +39,37 @@ mass resource-type publish mass resource-type publish ./my-resource-type ``` +## Publishing a raw JSON schema (deprecated) + +`path` may also point at a raw JSON (or YAML) schema file, the format that +predates `massdriver.yaml`: + +```bash +mass resource-type publish ./my-resource-type.json +``` + +This is **deprecated** and will be removed in a future release. A raw schema has +no version of its own, so it is published as the resource type's unversioned +`0.0.0` document and cannot participate in resource type versioning. + +Migrate with `mass resource-type convert`, which writes an equivalent +`massdriver.yaml` alongside the schema: + +```bash +mass resource-type convert ./my-resource-type.json +``` + ``` mass resource-type publish [path] [flags] ``` +### Examples + +``` +mass resource-type publish ./my-resource-type +``` + ### Options ``` diff --git a/docs/helpdocs/type/publish.md b/docs/helpdocs/type/publish.md index fda87ff2..852e799b 100644 --- a/docs/helpdocs/type/publish.md +++ b/docs/helpdocs/type/publish.md @@ -6,9 +6,6 @@ The resource type is authored as a `massdriver.yaml` file, which must include a `version` field. Publishing is immutable: a version that already exists cannot be republished. -Raw JSON schema publishing is no longer supported. If you have a raw JSON schema, -convert it first with `mass resource-type convert`. - ## Usage ```bash @@ -29,3 +26,23 @@ mass resource-type publish # Publish a resource type from a specific directory mass resource-type publish ./my-resource-type ``` + +## Publishing a raw JSON schema (deprecated) + +`path` may also point at a raw JSON (or YAML) schema file, the format that +predates `massdriver.yaml`: + +```bash +mass resource-type publish ./my-resource-type.json +``` + +This is **deprecated** and will be removed in a future release. A raw schema has +no version of its own, so it is published as the resource type's unversioned +`0.0.0` document and cannot participate in resource type versioning. + +Migrate with `mass resource-type convert`, which writes an equivalent +`massdriver.yaml` alongside the schema: + +```bash +mass resource-type convert ./my-resource-type.json +``` diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 00000000..ab7b574e --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,76 @@ +// Package api is a holding pen for GraphQL operations the massdriver-sdk-go +// doesn't expose. Today that's the single deprecated `publishResourceType` +// mutation, which backs `mass resource-type publish` for raw JSON schema files. +// +// The SDK deliberately omits it: resource types are OCI-hosted now, and the +// mutation is a transitional shim the API marks `@deprecated`. It survives here +// only so customers whose pipelines still publish raw JSON schemas keep working +// until they migrate with `mass resource-type convert`. When that mutation is +// removed server-side, delete this package. +package api + +import ( + "errors" + "fmt" + "strings" + + "github.com/Khan/genqlient/graphql" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql" +) + +// transportOverride is set by tests to short-circuit transport construction +// (we can't reach inside *massdriver.Client to get its graphql client, so +// tests need their own injection point). Production code leaves it nil and +// gqlClient builds a real transport from the resolved config. +var transportOverride graphql.Client + +// SetTransportForTest installs a graphql.Client that every api operation will +// use instead of the configured Massdriver transport. Tests pair this with +// gqltest.NewClient and t.Cleanup to scrub on teardown. +func SetTransportForTest(c graphql.Client) func() { + transportOverride = c + return func() { transportOverride = nil } +} + +// gqlClient builds a v2-shape GraphQL client from a *massdriver.Client's +// resolved config. Each call reconstructs the transport — cheap, and avoids +// stashing state in this package. +func gqlClient(mdClient *massdriver.Client) graphql.Client { + if transportOverride != nil { + return transportOverride + } + return gql.NewV2Client(mdClient.Config()) +} + +// mutationMessage is the per-field message bag returned by GraphQL mutations. +type mutationMessage struct { + Code string `json:"code"` + Field string `json:"field"` + Message string `json:"message"` +} + +// mutationError formats one or more mutation messages into a single error +// matching the legacy CLI's user-facing output. +func mutationError(label string, messages []mutationMessage) error { + if len(messages) == 0 { + return fmt.Errorf("%s: server reported failure with no detail", label) + } + var b strings.Builder + b.WriteString(label) + b.WriteByte(':') + for _, m := range messages { + b.WriteString("\n - ") + if m.Field != "" { + b.WriteString(m.Field) + b.WriteString(": ") + } + b.WriteString(m.Message) + if m.Code != "" { + b.WriteString(" (") + b.WriteString(m.Code) + b.WriteByte(')') + } + } + return errors.New(b.String()) +} diff --git a/internal/api/resource_type.go b/internal/api/resource_type.go new file mode 100644 index 00000000..65f1a3d1 --- /dev/null +++ b/internal/api/resource_type.go @@ -0,0 +1,89 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/Khan/genqlient/graphql" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql/scalars" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/resourcetypes" +) + +// PublishResourceTypeInput is the input for PublishResourceType. +type PublishResourceTypeInput struct { + Schema map[string]any `json:"schema"` +} + +// resourceTypeMutationResult is the wrapped payload the resource-type mutation +// returns. +type resourceTypeMutationResult struct { + Result *resourcetypes.ResourceType `json:"result"` + Successful bool `json:"successful"` + Messages []mutationMessage `json:"messages"` +} + +const publishResourceTypeMutation = `mutation publishResourceType($organizationId: ID!, $input: PublishResourceTypeInput!) { + publishResourceType(organizationId: $organizationId, input: $input) { + result { + id + name + version + icon + connectionOrientation + schema + createdAt + updatedAt + } + successful + messages { + code + field + message + } + } +}` + +// PublishResourceType upserts a resource type from a raw JSON Schema document. +// +// It wraps the API's transitional `publishResourceType` mutation, which the +// server marks deprecated: the schema is stored as the resource type's +// unversioned `0.0.0` document, so it can't participate in versioning. It backs +// the legacy branch of `mass resource-type publish` only — the massdriver.yaml +// path publishes through OCI instead (see +// internal/commands/resourcetype.RunPublish). No new callers. +func PublishResourceType(ctx context.Context, mdClient *massdriver.Client, input PublishResourceTypeInput) (*resourcetypes.ResourceType, error) { + cfg := mdClient.Config() + + // The schema field is a GraphQL `Map!` scalar — wire format is a + // JSON-encoded string. scalars.MarshalJSON is the canonical encoder the + // genqlient codegen uses; reuse it so the wire shape stays in lockstep. + schemaRaw, err := scalars.MarshalJSON(input.Schema) + if err != nil { + return nil, fmt.Errorf("marshal resource-type schema: %w", err) + } + + var resp struct { + PublishResourceType resourceTypeMutationResult `json:"publishResourceType"` + } + req := &graphql.Request{ + OpName: "publishResourceType", + Query: publishResourceTypeMutation, + Variables: map[string]any{ + "organizationId": cfg.OrganizationID, + "input": map[string]any{"schema": json.RawMessage(schemaRaw)}, + }, + } + if reqErr := gqlClient(mdClient).MakeRequest(ctx, req, &graphql.Response{Data: &resp}); reqErr != nil { + return nil, fmt.Errorf("publish resource type: %w", reqErr) + } + if !resp.PublishResourceType.Successful { + return nil, mutationError("publish resource type", resp.PublishResourceType.Messages) + } + if resp.PublishResourceType.Result == nil { + return nil, errors.New("publish resource type: server reported success but returned no resource type") + } + return resp.PublishResourceType.Result, nil +} diff --git a/internal/api/resource_type_test.go b/internal/api/resource_type_test.go new file mode 100644 index 00000000..f73412f8 --- /dev/null +++ b/internal/api/resource_type_test.go @@ -0,0 +1,144 @@ +package api_test + +import ( + "strings" + "testing" + + "github.com/massdriver-cloud/mass/internal/api" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql/gqltest" +) + +// newTestClient returns a *massdriver.Client with a resolved config (no +// credentials needed) plus the gqltest mock installed as the api package's +// transport for the duration of the test. +func newTestClient(t *testing.T, responses ...gqltest.Response) (*massdriver.Client, *gqltest.Client) { + t.Helper() + + mock := gqltest.NewClient(responses...) + t.Cleanup(api.SetTransportForTest(mock)) + + mdClient, err := massdriver.NewClient( + massdriver.WithGQLClient(mock), + massdriver.WithOrganizationID("test-org"), + ) + if err != nil { + t.Fatalf("failed to build test client: %v", err) + } + return mdClient, mock +} + +func TestPublishResourceType(t *testing.T) { + schema := map[string]any{ + "$md": map[string]any{"name": "aws-iam-role", "label": "AWS IAM Role"}, + "type": "object", + } + + mdClient, mock := newTestClient(t, gqltest.RespondWithData(map[string]any{ + "publishResourceType": map[string]any{ + "successful": true, + "messages": []any{}, + "result": map[string]any{ + "id": "aws-iam-role@0.0.0", + "name": "aws-iam-role", + "version": "0.0.0", + "connectionOrientation": "LINK", + "schema": schema, + }, + }, + })) + + got, err := api.PublishResourceType(t.Context(), mdClient, api.PublishResourceTypeInput{Schema: schema}) + if err != nil { + t.Fatalf("PublishResourceType returned an error: %v", err) + } + if got.Name != "aws-iam-role" { + t.Errorf("Name = %q, want aws-iam-role", got.Name) + } + if got.Version != "0.0.0" { + t.Errorf("Version = %q, want 0.0.0", got.Version) + } + + reqs := mock.Requests() + if len(reqs) != 1 { + t.Fatalf("got %d requests, want 1", len(reqs)) + } + if reqs[0].OpName != "publishResourceType" { + t.Errorf("OpName = %q, want publishResourceType", reqs[0].OpName) + } + if reqs[0].Variables["organizationId"] != "test-org" { + t.Errorf("organizationId = %v, want test-org", reqs[0].Variables["organizationId"]) + } +} + +// The schema arrives as a GraphQL `Map!` scalar, whose wire form is a +// JSON-encoded string rather than a nested object. Assert the encoding so a +// change to the scalar helper can't silently ship a payload the API rejects. +func TestPublishResourceTypeEncodesSchemaAsScalar(t *testing.T) { + mdClient, mock := newTestClient(t, gqltest.RespondWithData(map[string]any{ + "publishResourceType": map[string]any{ + "successful": true, + "result": map[string]any{"name": "aws-iam-role", "version": "0.0.0"}, + }, + })) + + _, err := api.PublishResourceType(t.Context(), mdClient, api.PublishResourceTypeInput{ + Schema: map[string]any{"type": "object"}, + }) + if err != nil { + t.Fatalf("PublishResourceType returned an error: %v", err) + } + + input, ok := mock.Requests()[0].Variables["input"].(map[string]any) + if !ok { + t.Fatalf("input variable = %T, want map[string]any", mock.Requests()[0].Variables["input"]) + } + sent, ok := input["schema"].(string) + if !ok { + t.Fatalf("input.schema = %T, want a JSON-encoded string", input["schema"]) + } + if !strings.Contains(sent, `"type":"object"`) { + t.Errorf("input.schema = %q, want it to contain the encoded schema", sent) + } +} + +func TestPublishResourceTypeUnsuccessful(t *testing.T) { + mdClient, _ := newTestClient(t, gqltest.RespondWithData(map[string]any{ + "publishResourceType": map[string]any{ + "successful": false, + "messages": []any{ + map[string]any{"code": "invalid", "field": "schema", "message": "is invalid"}, + }, + }, + })) + + _, err := api.PublishResourceType(t.Context(), mdClient, api.PublishResourceTypeInput{ + Schema: map[string]any{"type": "object"}, + }) + if err == nil { + t.Fatal("expected an error, got nil") + } + for _, want := range []string{"publish resource type", "schema: is invalid", "(invalid)"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q missing %q", err.Error(), want) + } + } +} + +// A successful:true response with a null result would otherwise be dereferenced +// straight into a nil-pointer panic. +func TestPublishResourceTypeSuccessfulWithNoResult(t *testing.T) { + mdClient, _ := newTestClient(t, gqltest.RespondWithData(map[string]any{ + "publishResourceType": map[string]any{"successful": true, "result": nil}, + })) + + _, err := api.PublishResourceType(t.Context(), mdClient, api.PublishResourceTypeInput{ + Schema: map[string]any{"type": "object"}, + }) + if err == nil { + t.Fatal("expected an error, got nil") + } + if !strings.Contains(err.Error(), "returned no resource type") { + t.Errorf("unexpected error: %v", err) + } +} diff --git a/internal/commands/resourcetype/publish.go b/internal/commands/resourcetype/publish.go index e07489f6..0b1b5e8c 100644 --- a/internal/commands/resourcetype/publish.go +++ b/internal/commands/resourcetype/publish.go @@ -12,6 +12,7 @@ import ( "slices" "strings" + "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/jsonschema" "github.com/massdriver-cloud/mass/internal/oci" "github.com/massdriver-cloud/mass/internal/prettylogs" @@ -109,15 +110,20 @@ func normalizeRel(p string) string { return cleaned } -// RunPublish validates a resource type located at path and pushes it to its OCI -// repository. path may be a directory containing a massdriver.yaml, or the -// massdriver.yaml itself. It returns the resource type name and the published -// version. +// RunPublish validates a resource type located at path and publishes it. path +// may be a directory containing a massdriver.yaml, the massdriver.yaml itself, +// or — via the deprecated legacy path — a raw JSON/YAML schema file. It returns +// the resource type name and the published version. func RunPublish(ctx context.Context, mdClient *massdriver.Client, path string) (string, string, error) { - mdYamlPath, srcDir, resolveErr := resolvePublishPath(path) + target, resolveErr := resolvePublishPath(path) if resolveErr != nil { return "", "", resolveErr } + if target.legacy { + return publishLegacySchema(ctx, mdClient, target.path) + } + + mdYamlPath, srcDir := target.path, target.srcDir config, configErr := resourcetype.ReadConfig(mdYamlPath) if configErr != nil { @@ -168,35 +174,95 @@ func RunPublish(ctx context.Context, mdClient *massdriver.Client, path string) ( return config.Name, config.Version, nil } -// resolvePublishPath resolves the publish target into the massdriver.yaml path -// and its containing directory, rejecting raw JSON schema files with a pointer -// to the convert command. -func resolvePublishPath(path string) (mdYamlPath string, srcDir string, err error) { +// publishTarget is the resolved shape of a publish argument: either a +// massdriver.yaml plus the directory to package (the OCI flow), or a raw +// JSON/YAML schema file (the deprecated legacy flow). +type publishTarget struct { + // path is the massdriver.yaml, or the raw schema file when legacy is set. + path string + // srcDir is the directory packaged into the OCI artifact. Unused when + // legacy is set — the legacy mutation publishes a schema document, not a + // directory. + srcDir string + legacy bool +} + +// resolvePublishPath resolves the publish target. A directory or massdriver.yaml +// takes the OCI flow; a bare .json/.yaml/.yml schema file takes the deprecated +// legacy flow. +func resolvePublishPath(path string) (publishTarget, error) { info, statErr := os.Stat(path) if statErr != nil { - return "", "", fmt.Errorf("failed to read resource type path: %w", statErr) + return publishTarget{}, fmt.Errorf("failed to read resource type path: %w", statErr) } if info.IsDir() { md := filepath.Join(path, "massdriver.yaml") if _, mdErr := os.Stat(md); mdErr != nil { - return "", "", fmt.Errorf("no massdriver.yaml found in %s", path) + return publishTarget{}, fmt.Errorf("no massdriver.yaml found in %s", path) } - return md, path, nil + return publishTarget{path: md, srcDir: path}, nil } if filepath.Base(path) == "massdriver.yaml" { - return path, filepath.Dir(path), nil + return publishTarget{path: path, srcDir: filepath.Dir(path)}, nil } switch strings.ToLower(filepath.Ext(path)) { case ".json", ".yaml", ".yml": - return "", "", fmt.Errorf("publishing a raw JSON schema is no longer supported; run `mass resource-type convert %s` to migrate it to a massdriver.yaml", path) + return publishTarget{path: path, legacy: true}, nil default: - return "", "", fmt.Errorf("unsupported resource type path: %s (expected a directory or massdriver.yaml)", path) + return publishTarget{}, fmt.Errorf("unsupported resource type path: %s (expected a directory, a massdriver.yaml, or a JSON schema file)", path) } } +// publishLegacySchema publishes a raw JSON/YAML schema document through the +// deprecated `publishResourceType` mutation. The schema has no version of its +// own, so the API stores it as the resource type's unversioned 0.0.0 document — +// which is why this flow can't participate in resource type versioning and is +// on its way out. +func publishLegacySchema(ctx context.Context, mdClient *massdriver.Client, path string) (string, string, error) { + // Warn before any work so the notice lands whether or not the publish + // itself succeeds. + warnLegacySchema(path) + + rt, readErr := resourcetype.Read(ctx, mdClient, path) + if readErr != nil { + return "", "", fmt.Errorf("failed to read resource type: %w", readErr) + } + + if validateErr := validateBuiltSchema(mdClient, rt); validateErr != nil { + return "", "", validateErr + } + + published, publishErr := api.PublishResourceType(ctx, mdClient, api.PublishResourceTypeInput{Schema: rt}) + if publishErr != nil { + return "", "", publishErr + } + + version := published.Version + if version == "" { + version = legacySchemaVersion + } + return published.Name, version, nil +} + +// legacySchemaVersion is the unversioned document the legacy mutation writes to. +// Used only as a display fallback if the API omits the version in its response. +const legacySchemaVersion = "0.0.0" + +// warnLegacySchema tells the user their raw JSON schema is on a deprecated, +// unversioned path and points them at `resource-type convert`. +// Printed as separate lines rather than one multi-line string: lipgloss pads +// every line of a styled block to the width of its longest line, which leaves +// ragged trailing whitespace once a long file path is interpolated in. +func warnLegacySchema(path string) { + fmt.Println(prettylogs.Orange("Warning: this resource type is a raw JSON schema. That format is deprecated, does not support")) + fmt.Println(prettylogs.Orange("versioning, and will be removed in a future release. Migrate it to the massdriver.yaml")) + fmt.Println(prettylogs.Orange("format, which supports versioning, by running:")) + fmt.Println(prettylogs.Orange(fmt.Sprintf(" mass resource-type convert %s", path))) +} + // validateSchema builds and dereferences the resource type, then validates it // against the resource type schema and the JSON Schema meta-schema. func validateSchema(ctx context.Context, mdClient *massdriver.Client, mdYamlPath string) error { @@ -204,7 +270,12 @@ func validateSchema(ctx context.Context, mdClient *massdriver.Client, mdYamlPath if readErr != nil { return fmt.Errorf("failed to read resource type: %w", readErr) } + return validateBuiltSchema(mdClient, rt) +} +// validateBuiltSchema validates an already read-and-dereferenced resource type +// against the resource type schema and the JSON Schema meta-schema. +func validateBuiltSchema(mdClient *massdriver.Client, rt map[string]any) error { cfg := mdClient.Config() rtSchemaURL, err := url.JoinPath(cfg.URL, "json-schemas", "resource-type.json") if err != nil { diff --git a/internal/commands/resourcetype/publish_test.go b/internal/commands/resourcetype/publish_test.go index bbaa76ae..91147f28 100644 --- a/internal/commands/resourcetype/publish_test.go +++ b/internal/commands/resourcetype/publish_test.go @@ -10,15 +10,15 @@ import ( ) // TestRunPublishValidation covers the local validation RunPublish performs -// before it touches the OCI registry: rejecting raw schema files (pointing at -// convert) and requiring a name in the massdriver.yaml. These paths -// short-circuit before the massdriver client is used, so a nil client is fine. -// (A missing version is not an error — it warns and defaults to 0.0.0.) +// before it touches the OCI registry: rejecting unsupported file types and +// requiring a name in the massdriver.yaml. These paths short-circuit before the +// massdriver client is used, so a nil client is fine. (A missing version is not +// an error — it warns and defaults to 0.0.0.) func TestRunPublishValidation(t *testing.T) { dir := t.TempDir() - rawJSON := filepath.Join(dir, "schema.json") - if err := os.WriteFile(rawJSON, []byte("{}"), 0600); err != nil { + unsupported := filepath.Join(dir, "schema.txt") + if err := os.WriteFile(unsupported, []byte("{}"), 0600); err != nil { t.Fatal(err) } noNameDir := t.TempDir() @@ -32,7 +32,7 @@ func TestRunPublishValidation(t *testing.T) { path string contains string }{ - {name: "raw JSON schema rejected", path: rawJSON, contains: "convert"}, + {name: "unsupported file type", path: unsupported, contains: "unsupported resource type path"}, {name: "directory without massdriver.yaml", path: emptyDir, contains: "no massdriver.yaml"}, {name: "missing name", path: noNameDir, contains: "name is required"}, } diff --git a/internal/commands/resourcetype/resolve_test.go b/internal/commands/resourcetype/resolve_test.go new file mode 100644 index 00000000..4a4cdb73 --- /dev/null +++ b/internal/commands/resourcetype/resolve_test.go @@ -0,0 +1,81 @@ +package resourcetype //nolint:testpackage // needs access to unexported resolvePublishPath + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestResolvePublishPath pins which publish argument shapes take the OCI flow +// and which fall back to the deprecated raw-schema flow. Getting this wrong +// either breaks customers still publishing JSON schemas or silently pushes a +// massdriver.yaml through the unversioned legacy mutation. +func TestResolvePublishPath(t *testing.T) { + dir := t.TempDir() + write := func(name string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{}"), 0600); err != nil { + t.Fatal(err) + } + return path + } + + mdYamlDir := filepath.Join(dir, "rt") + mdYaml := write("rt/massdriver.yaml") + jsonSchema := write("schema.json") + yamlSchema := write("schema.yaml") + ymlSchema := write("schema.yml") + upperJSON := write("Schema.JSON") + unsupported := write("schema.txt") + + tests := []struct { + name string + path string + wantPath string + wantSrcDir string + wantLegacy bool + wantErr string + }{ + {name: "directory with massdriver.yaml", path: mdYamlDir, wantPath: mdYaml, wantSrcDir: mdYamlDir}, + {name: "massdriver.yaml file", path: mdYaml, wantPath: mdYaml, wantSrcDir: mdYamlDir}, + {name: "json schema is legacy", path: jsonSchema, wantPath: jsonSchema, wantLegacy: true}, + {name: "yaml schema is legacy", path: yamlSchema, wantPath: yamlSchema, wantLegacy: true}, + {name: "yml schema is legacy", path: ymlSchema, wantPath: ymlSchema, wantLegacy: true}, + {name: "extension match is case-insensitive", path: upperJSON, wantPath: upperJSON, wantLegacy: true}, + {name: "unsupported extension", path: unsupported, wantErr: "unsupported resource type path"}, + {name: "directory without massdriver.yaml", path: t.TempDir(), wantErr: "no massdriver.yaml"}, + {name: "missing path", path: filepath.Join(dir, "nope"), wantErr: "failed to read resource type path"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := resolvePublishPath(tc.path) + if tc.wantErr != "" { + if err == nil { + t.Fatalf("expected an error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error %q missing %q", err.Error(), tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("resolvePublishPath returned an error: %v", err) + } + if got.path != tc.wantPath { + t.Errorf("path = %q, want %q", got.path, tc.wantPath) + } + if got.srcDir != tc.wantSrcDir { + t.Errorf("srcDir = %q, want %q", got.srcDir, tc.wantSrcDir) + } + if got.legacy != tc.wantLegacy { + t.Errorf("legacy = %v, want %v", got.legacy, tc.wantLegacy) + } + }) + } +} From e7f824439b892dd3fde4b1e958410fca4a71c2d3 Mon Sep 17 00:00:00 2001 From: chrisghill Date: Tue, 1 Sep 2026 17:07:10 -0600 Subject: [PATCH 15/19] Update docs --- docs/helpdocs/type/publish.md | 11 +++++++---- internal/commands/resourcetype/publish.go | 4 +--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/helpdocs/type/publish.md b/docs/helpdocs/type/publish.md index 852e799b..9da76e58 100644 --- a/docs/helpdocs/type/publish.md +++ b/docs/helpdocs/type/publish.md @@ -12,10 +12,10 @@ republished. mass resource-type publish [path] ``` -`path` is a directory containing a `massdriver.yaml` (defaults to the current -directory). Only `massdriver.yaml`, `readme`, `changelog`, icon files, and the -instruction/export template files referenced by the `massdriver.yaml` are -included in the published artifact. +`path` is a directory containing a `massdriver.yaml`, or the `massdriver.yaml` +itself (defaults to the current directory). Only `massdriver.yaml`, `readme`, +`changelog`, icon files, and the instruction/export template files referenced +by the `massdriver.yaml` are included in the published artifact. ## Examples @@ -25,6 +25,9 @@ mass resource-type publish # Publish a resource type from a specific directory mass resource-type publish ./my-resource-type + +# Or point directly at the massdriver.yaml +mass resource-type publish ./my-resource-type/massdriver.yaml ``` ## Publishing a raw JSON schema (deprecated) diff --git a/internal/commands/resourcetype/publish.go b/internal/commands/resourcetype/publish.go index 0b1b5e8c..0b31b871 100644 --- a/internal/commands/resourcetype/publish.go +++ b/internal/commands/resourcetype/publish.go @@ -257,9 +257,7 @@ const legacySchemaVersion = "0.0.0" // every line of a styled block to the width of its longest line, which leaves // ragged trailing whitespace once a long file path is interpolated in. func warnLegacySchema(path string) { - fmt.Println(prettylogs.Orange("Warning: this resource type is a raw JSON schema. That format is deprecated, does not support")) - fmt.Println(prettylogs.Orange("versioning, and will be removed in a future release. Migrate it to the massdriver.yaml")) - fmt.Println(prettylogs.Orange("format, which supports versioning, by running:")) + fmt.Println(prettylogs.Orange("Warning: this resource type is a raw JSON schema. That format is deprecated, does not support versioning, and will be removed in a future release. Migrate it to the massdriver.yaml format, which supports versioning, by running:")) fmt.Println(prettylogs.Orange(fmt.Sprintf(" mass resource-type convert %s", path))) } From 7446768da5413656933a229855fbd5633a8c910a Mon Sep 17 00:00:00 2001 From: chrisghill Date: Tue, 1 Sep 2026 17:27:52 -0600 Subject: [PATCH 16/19] add tests --- docs/generated/mass_resource-type_publish.md | 11 +- .../commands/resourcetype/publish_test.go | 236 +++++++++++++- internal/oci/publish_test.go | 299 ++++++++++++++++++ 3 files changed, 539 insertions(+), 7 deletions(-) create mode 100644 internal/oci/publish_test.go diff --git a/docs/generated/mass_resource-type_publish.md b/docs/generated/mass_resource-type_publish.md index 7952d020..7ed10fe0 100644 --- a/docs/generated/mass_resource-type_publish.md +++ b/docs/generated/mass_resource-type_publish.md @@ -24,10 +24,10 @@ republished. mass resource-type publish [path] ``` -`path` is a directory containing a `massdriver.yaml` (defaults to the current -directory). Only `massdriver.yaml`, `readme`, `changelog`, icon files, and the -instruction/export template files referenced by the `massdriver.yaml` are -included in the published artifact. +`path` is a directory containing a `massdriver.yaml`, or the `massdriver.yaml` +itself (defaults to the current directory). Only `massdriver.yaml`, `readme`, +`changelog`, icon files, and the instruction/export template files referenced +by the `massdriver.yaml` are included in the published artifact. ## Examples @@ -37,6 +37,9 @@ mass resource-type publish # Publish a resource type from a specific directory mass resource-type publish ./my-resource-type + +# Or point directly at the massdriver.yaml +mass resource-type publish ./my-resource-type/massdriver.yaml ``` ## Publishing a raw JSON schema (deprecated) diff --git a/internal/commands/resourcetype/publish_test.go b/internal/commands/resourcetype/publish_test.go index 91147f28..f6fd8576 100644 --- a/internal/commands/resourcetype/publish_test.go +++ b/internal/commands/resourcetype/publish_test.go @@ -1,12 +1,15 @@ -package resourcetype_test +package resourcetype //nolint:testpackage // needs access to unexported checkDuplicateVersion import ( + "encoding/json" "os" "path/filepath" "strings" "testing" - cmdresourcetype "github.com/massdriver-cloud/mass/internal/commands/resourcetype" + "github.com/massdriver-cloud/mass/internal/api" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql/gqltest" ) // TestRunPublishValidation covers the local validation RunPublish performs @@ -39,7 +42,7 @@ func TestRunPublishValidation(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - _, _, err := cmdresourcetype.RunPublish(t.Context(), nil, tc.path) + _, _, err := RunPublish(t.Context(), nil, tc.path) if err == nil { t.Fatalf("expected an error, got nil") } @@ -49,3 +52,230 @@ func TestRunPublishValidation(t *testing.T) { }) } } + +// schemaDir writes the two json-schemas documents RunPublish validates +// against and returns a file:// base URL pointing at them. The schema loader +// handles file:// the same as https://, so this exercises the real validation +// path without standing up an HTTP server. +func schemaDir(t *testing.T, resourceTypeSchema map[string]any) string { + t.Helper() + + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "json-schemas"), 0750); err != nil { + t.Fatal(err) + } + write := func(name string, schema map[string]any) { + body, err := json.Marshal(schema) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "json-schemas", name), body, 0600); err != nil { + t.Fatal(err) + } + } + write("resource-type.json", resourceTypeSchema) + write("draft-7.json", map[string]any{"$schema": "http://json-schema.org/draft-07/schema#", "type": "object"}) + + return "file://" + dir +} + +// legacyClient wires a client at the schema directory with the gql mock +// installed as both the SDK and api-package transport. +func legacyClient(t *testing.T, baseURL string, responses ...gqltest.Response) *massdriver.Client { + t.Helper() + + mock := gqltest.NewClient(responses...) + t.Cleanup(api.SetTransportForTest(mock)) + + mdClient, err := massdriver.NewClient( + massdriver.WithGQLClient(mock), + massdriver.WithOrganizationID("test-org"), + massdriver.WithBaseURL(baseURL), + ) + if err != nil { + t.Fatalf("failed to build test client: %v", err) + } + return mdClient +} + +// captureStdout runs fn with os.Stdout pointed at a temp file and returns what +// it printed. The deprecation notice is the whole point of the legacy path, so +// it has to be asserted on. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + f, err := os.CreateTemp(t.TempDir(), "stdout") + if err != nil { + t.Fatal(err) + } + orig := os.Stdout + os.Stdout = f //nolint:reassign // capturing the deprecation notice + defer func() { os.Stdout = orig }() //nolint:reassign // restore + + fn() + + out, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatal(err) + } + return string(out) +} + +func writeSchema(t *testing.T, name, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + return path +} + +const legacySchemaJSON = `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://schemas.massdriver.cloud/aws-iam-role.json", + "title": "AWS IAM Role", + "type": "object", + "properties": {"arn": {"type": "string"}} +}` + +// TestRunPublishLegacySchema covers the deprecated raw-schema flow end to end: +// it must route away from OCI, validate, publish through the legacy mutation, +// and warn the user. This path was dropped and re-added once already (commit +// 1e16b3b), so it is pinned here. +func TestRunPublishLegacySchema(t *testing.T) { + path := writeSchema(t, "aws-iam-role.json", legacySchemaJSON) + + mdClient := legacyClient(t, schemaDir(t, map[string]any{"type": "object"}), gqltest.RespondWithData(map[string]any{ + "publishResourceType": map[string]any{ + "successful": true, + "messages": []any{}, + "result": map[string]any{ + "id": "aws-iam-role@0.0.0", + "name": "aws-iam-role", + "version": "0.0.0", + }, + }, + })) + + var name, version string + var publishErr error + out := captureStdout(t, func() { + name, version, publishErr = RunPublish(t.Context(), mdClient, path) + }) + + if publishErr != nil { + t.Fatalf("RunPublish returned an error: %v", publishErr) + } + if name != "aws-iam-role" { + t.Errorf("name = %q, want aws-iam-role", name) + } + // A raw schema has no version of its own; the API stores it as the + // unversioned 0.0.0 document. + if version != "0.0.0" { + t.Errorf("version = %q, want 0.0.0", version) + } + if !strings.Contains(out, "deprecated") { + t.Errorf("expected a deprecation warning on stdout, got: %q", out) + } + if !strings.Contains(out, "mass resource-type convert "+path) { + t.Errorf("expected the warning to point at the convert command for %s, got: %q", path, out) + } +} + +// repoWithTags builds a client whose OciRepos.Get returns a repository carrying +// the given published tags. Get selects tags through a paginated `items` +// envelope, which is the shape the SDK unwraps. +func repoWithTags(t *testing.T, name string, tags ...string) *massdriver.Client { + t.Helper() + + items := make([]map[string]any, 0, len(tags)) + for _, tag := range tags { + items = append(items, map[string]any{"tag": tag}) + } + + return newMockClient(t, gqltest.RespondWithData(map[string]any{ + "ociRepo": map[string]any{ + "id": name, + "name": name, + "artifactType": "application/vnd.massdriver.resource-type.v1+json", + "tags": map[string]any{"items": items}, + }, + })) +} + +func newMockClient(t *testing.T, responses ...gqltest.Response) *massdriver.Client { + t.Helper() + mdClient, err := massdriver.NewClient( + massdriver.WithGQLClient(gqltest.NewClient(responses...)), + massdriver.WithOrganizationID("test-org"), + ) + if err != nil { + t.Fatal(err) + } + return mdClient +} + +// TestCheckDuplicateVersion pins the local half of publish immutability. Too +// strict and valid publishes are blocked before they reach the API; too loose +// and the user only learns of the collision after the packaging work. +func TestCheckDuplicateVersion(t *testing.T) { + tests := []struct { + name string + client func(t *testing.T) *massdriver.Client + version string + wantErr string + wantPass bool + }{ + { + name: "version not yet published", + client: func(t *testing.T) *massdriver.Client { return repoWithTags(t, "aws-s3-bucket", "1.0.0", "1.1.0") }, + version: "2.0.0", + wantPass: true, + }, + { + name: "version already published", + client: func(t *testing.T) *massdriver.Client { return repoWithTags(t, "aws-s3-bucket", "1.0.0", "2.0.0") }, + version: "2.0.0", + wantErr: "version 2.0.0 already exists for resource type aws-s3-bucket", + }, + { + name: "repository has no published versions", + client: func(t *testing.T) *massdriver.Client { return repoWithTags(t, "aws-s3-bucket") }, + version: "1.0.0", + wantPass: true, + }, + { + name: "api failure is surfaced", + client: func(t *testing.T) *massdriver.Client { return newMockClient(t, gqltest.RespondWithError("boom")) }, + version: "1.0.0", + wantErr: "fetching OCI repo", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := checkDuplicateVersion(t.Context(), tc.client(t), "aws-s3-bucket", tc.version) + if tc.wantPass { + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected an error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error %q missing %q", err.Error(), tc.wantErr) + } + }) + } +} + +// TestCheckDuplicateVersionDevTagSkipsAPI pins that 0.0.0 is always +// republishable. The nil client is the assertion: if the carve-out ever stops +// short-circuiting, this panics rather than silently costing a round trip. +func TestCheckDuplicateVersionDevTagSkipsAPI(t *testing.T) { + if err := checkDuplicateVersion(t.Context(), nil, "aws-s3-bucket", "0.0.0"); err != nil { + t.Fatalf("0.0.0 should always be republishable, got: %v", err) + } +} diff --git a/internal/oci/publish_test.go b/internal/oci/publish_test.go new file mode 100644 index 00000000..227c22d2 --- /dev/null +++ b/internal/oci/publish_test.go @@ -0,0 +1,299 @@ +package oci_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + "github.com/massdriver-cloud/mass/internal/oci" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + oras "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/memory" +) + +// writeTree materializes a path->contents map under a fresh temp dir, +// creating parent directories as needed. +func writeTree(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for rel, body := range files { + full := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + return dir +} + +// fetchManifest resolves a descriptor into a decoded OCI manifest. +func fetchManifest(t *testing.T, ctx context.Context, store oras.Target, desc ocispec.Descriptor) ocispec.Manifest { + t.Helper() + rc, err := store.Fetch(ctx, desc) + if err != nil { + t.Fatalf("fetching manifest: %v", err) + } + defer rc.Close() + var manifest ocispec.Manifest + if decodeErr := json.NewDecoder(rc).Decode(&manifest); decodeErr != nil { + t.Fatalf("decoding manifest: %v", decodeErr) + } + return manifest +} + +// layerTitles maps each layer's file title to its descriptor. +func layerTitles(manifest ocispec.Manifest) map[string]ocispec.Descriptor { + titles := map[string]ocispec.Descriptor{} + for _, l := range manifest.Layers { + titles[l.Annotations[ocispec.AnnotationTitle]] = l + } + return titles +} + +// countingTarget records how many content layers (descriptors carrying a file +// title) were pushed, so deduplication can be asserted directly rather than +// inferred from the manifest. +type countingTarget struct { + oras.Target + filePushes int +} + +func (c *countingTarget) Push(ctx context.Context, desc ocispec.Descriptor, r io.Reader) error { + if desc.Annotations[ocispec.AnnotationTitle] != "" { + c.filePushes++ + } + return c.Target.Push(ctx, desc, r) +} + +// TestPackage covers the core packaging contract: the keep predicate decides +// what ships, nested files keep slash-separated relative titles, and the +// artifact type lands on the manifest under the requested tag. +func TestPackage(t *testing.T) { + srcDir := writeTree(t, map[string]string{ + "massdriver.yaml": "name: aws-s3-bucket\nversion: 1.0.0\n", + "README.md": "# readme", + "instructions/cli.md": "run it", + ".terraform/junk.tf": "should not ship", + "nested/deep/skip.txt": "should not ship", + }) + + keep := func(relPath string) bool { + switch relPath { + case "massdriver.yaml", "README.md", "instructions/cli.md": + return true + default: + return false + } + } + + store := memory.New() + publisher := &oci.Publisher{Store: store} + + desc, err := publisher.Package(t.Context(), srcDir, "1.0.0", "application/vnd.massdriver.resource-type.v1+json", keep) + if err != nil { + t.Fatalf("Package returned an error: %v", err) + } + + manifest := fetchManifest(t, t.Context(), store, desc) + titles := layerTitles(manifest) + + want := []string{"massdriver.yaml", "README.md", "instructions/cli.md"} + if len(titles) != len(want) { + t.Errorf("packaged %d layers (%v), want %d", len(titles), titles, len(want)) + } + for _, w := range want { + if _, ok := titles[w]; !ok { + t.Errorf("expected layer %q to be packaged, got %v", w, titles) + } + } + for _, skipped := range []string{".terraform/junk.tf", "nested/deep/skip.txt"} { + if _, ok := titles[skipped]; ok { + t.Errorf("layer %q should have been filtered out by the keep predicate", skipped) + } + } + + if manifest.ArtifactType != "application/vnd.massdriver.resource-type.v1+json" { + t.Errorf("ArtifactType = %q, want application/vnd.massdriver.resource-type.v1+json", manifest.ArtifactType) + } + + // The media type is derived per file from its extension. + if got := titles["instructions/cli.md"].MediaType; got != "text/markdown" { + t.Errorf("instructions/cli.md MediaType = %q, want text/markdown", got) + } + if got := titles["massdriver.yaml"].MediaType; got != "application/yaml" { + t.Errorf("massdriver.yaml MediaType = %q, want application/yaml", got) + } + + // The manifest must be reachable by the tag Package assigned. + resolved, resolveErr := store.Resolve(t.Context(), "1.0.0") + if resolveErr != nil { + t.Fatalf("resolving tag: %v", resolveErr) + } + if resolved.Digest != desc.Digest { + t.Errorf("tag 1.0.0 resolves to %s, want %s", resolved.Digest, desc.Digest) + } +} + +// TestPackageNilKeep pins that a nil predicate means "include everything", +// which is the documented contract for callers that don't filter. +func TestPackageNilKeep(t *testing.T) { + srcDir := writeTree(t, map[string]string{ + "massdriver.yaml": "name: test\n", + "src/main.tf": "resource {}", + }) + + store := memory.New() + publisher := &oci.Publisher{Store: store} + + desc, err := publisher.Package(t.Context(), srcDir, "latest", "application/vnd.massdriver.bundle.v1+json", nil) + if err != nil { + t.Fatalf("Package returned an error: %v", err) + } + + titles := layerTitles(fetchManifest(t, t.Context(), store, desc)) + for _, want := range []string{"massdriver.yaml", "src/main.tf"} { + if _, ok := titles[want]; !ok { + t.Errorf("expected layer %q with a nil keep predicate, got %v", want, titles) + } + } +} + +// TestPackageDeduplicatesIdenticalContent pins the blob-reuse behavior: two +// files with identical bytes are pushed once but still produce two manifest +// layers, so both paths unpack on pull. A regression here either bloats the +// artifact or silently drops one of the files. +func TestPackageDeduplicatesIdenticalContent(t *testing.T) { + srcDir := writeTree(t, map[string]string{ + "icon.svg": "", + "instructions/dup.md": "same bytes", + "instructions/two.md": "same bytes", + }) + + store := &countingTarget{Target: memory.New()} + publisher := &oci.Publisher{Store: store} + + desc, err := publisher.Package(t.Context(), srcDir, "1.0.0", "application/vnd.massdriver.resource-type.v1+json", nil) + if err != nil { + t.Fatalf("Package returned an error: %v", err) + } + + titles := layerTitles(fetchManifest(t, t.Context(), store, desc)) + if len(titles) != 3 { + t.Fatalf("got %d distinct layer titles (%v), want 3", len(titles), titles) + } + + dup, two := titles["instructions/dup.md"], titles["instructions/two.md"] + if dup.Digest != two.Digest { + t.Errorf("identical files should share a digest: %s vs %s", dup.Digest, two.Digest) + } + if store.filePushes != 2 { + t.Errorf("pushed %d file blobs, want 2 (the duplicate should be pushed once)", store.filePushes) + } +} + +// TestPackageExtensionlessFile documents what happens to files the mime table +// doesn't cover (LICENSE, Dockerfile). They still ship; only the media type is +// empty. +func TestPackageExtensionlessFile(t *testing.T) { + srcDir := writeTree(t, map[string]string{"LICENSE": "MIT"}) + + store := memory.New() + publisher := &oci.Publisher{Store: store} + + desc, err := publisher.Package(t.Context(), srcDir, "1.0.0", "application/vnd.massdriver.bundle.v1+json", nil) + if err != nil { + t.Fatalf("Package returned an error: %v", err) + } + + titles := layerTitles(fetchManifest(t, t.Context(), store, desc)) + if _, ok := titles["LICENSE"]; !ok { + t.Fatalf("extensionless file was not packaged, got %v", titles) + } +} + +// TestPackageMissingSourceDir ensures a bad source path surfaces as an error +// rather than an empty, successfully-published artifact. +func TestPackageMissingSourceDir(t *testing.T) { + publisher := &oci.Publisher{Store: memory.New()} + _, err := publisher.Package(t.Context(), filepath.Join(t.TempDir(), "nope"), "1.0.0", "application/vnd.massdriver.bundle.v1+json", nil) + if err == nil { + t.Fatal("expected an error packaging a nonexistent directory, got nil") + } +} + +// TestPublish covers the store->repo copy, including that layer content +// survives the round trip. +func TestPublish(t *testing.T) { + srcDir := writeTree(t, map[string]string{"massdriver.yaml": "name: aws-s3-bucket\n"}) + + store, repo := memory.New(), memory.New() + publisher := &oci.Publisher{Store: store, Repo: repo} + + desc, err := publisher.Package(t.Context(), srcDir, "1.0.0", "application/vnd.massdriver.resource-type.v1+json", nil) + if err != nil { + t.Fatalf("Package returned an error: %v", err) + } + if publishErr := publisher.Publish(t.Context(), "1.0.0"); publishErr != nil { + t.Fatalf("Publish returned an error: %v", publishErr) + } + + resolved, resolveErr := repo.Resolve(t.Context(), "1.0.0") + if resolveErr != nil { + t.Fatalf("tag was not published to the repo: %v", resolveErr) + } + if resolved.Digest != desc.Digest { + t.Errorf("repo tag resolves to %s, want %s", resolved.Digest, desc.Digest) + } + + layer := layerTitles(fetchManifest(t, t.Context(), repo, resolved))["massdriver.yaml"] + rc, fetchErr := repo.Fetch(t.Context(), layer) + if fetchErr != nil { + t.Fatalf("fetching layer from repo: %v", fetchErr) + } + defer rc.Close() + body, _ := io.ReadAll(rc) + if !bytes.Equal(body, []byte("name: aws-s3-bucket\n")) { + t.Errorf("layer content = %q, want %q", body, "name: aws-s3-bucket\n") + } +} + +// TestPublishUntaggedManifest guards the ordering contract: publishing a tag +// that was never packaged must fail rather than push a partial artifact. +func TestPublishUntaggedManifest(t *testing.T) { + publisher := &oci.Publisher{Store: memory.New(), Repo: memory.New()} + if err := publisher.Publish(t.Context(), "1.0.0"); err == nil { + t.Fatal("expected an error publishing a tag that was never packaged, got nil") + } +} + +func TestMimeTypeFromExtension(t *testing.T) { + tests := []struct { + ext string + want string + }{ + {ext: ".md", want: "text/markdown"}, + {ext: ".yaml", want: "application/yaml"}, + {ext: ".yml", want: "application/yaml"}, + {ext: ".json", want: "application/json"}, + {ext: ".tf", want: "application/hcl"}, + {ext: ".svg", want: "image/svg+xml"}, + {ext: ".png", want: "image/png"}, + // Unknown and extensionless inputs fall back to the empty string. + {ext: ".xyz", want: ""}, + {ext: "", want: ""}, + } + + for _, tc := range tests { + t.Run(tc.ext, func(t *testing.T) { + if got := oci.MimeTypeFromExtension(tc.ext); got != tc.want { + t.Errorf("MimeTypeFromExtension(%q) = %q, want %q", tc.ext, got, tc.want) + } + }) + } +} From a5307fcd3db4dfda8f99f044ae7c6ae65bb61575 Mon Sep 17 00:00:00 2001 From: chrisghill Date: Tue, 1 Sep 2026 22:45:54 -0600 Subject: [PATCH 17/19] bug fix and clean up comments --- cmd/resource_type.go | 7 +- internal/api/api.go | 13 +-- internal/api/resource_type.go | 13 +-- internal/api/resource_type_test.go | 8 +- internal/bundle/bundle.go | 27 ++---- internal/bundle/bundle_test.go | 4 +- internal/bundle/publish.go | 4 +- internal/commands/bundle/build.go | 10 +-- internal/commands/bundle/publish.go | 3 +- internal/commands/repository/artifacttype.go | 16 +++- .../commands/repository/artifacttype_test.go | 33 +++++++ internal/commands/resourcetype/convert.go | 31 ++----- .../commands/resourcetype/convert_test.go | 14 +-- internal/commands/resourcetype/delete.go | 6 +- internal/commands/resourcetype/publish.go | 89 ++++++------------- .../commands/resourcetype/publish_test.go | 46 +++------- internal/commands/resourcetype/pull.go | 11 +-- .../commands/resourcetype/resolve_test.go | 6 +- internal/oci/oci.go | 27 +++--- internal/oci/publish_test.go | 31 +------ internal/resourcetype/build.go | 6 +- internal/resourcetype/dereference.go | 6 +- internal/resourcetype/get.go | 14 +-- internal/resourcetype/get_test.go | 2 - 24 files changed, 155 insertions(+), 272 deletions(-) diff --git a/cmd/resource_type.go b/cmd/resource_type.go index 205968f7..b4be11d6 100644 --- a/cmd/resource_type.go +++ b/cmd/resource_type.go @@ -224,7 +224,6 @@ func runTypePull(cmd *cobra.Command, args []string) error { force, _ := cmd.Flags().GetBool("force") cmd.SilenceUsage = true - // Warn before overwriting an existing resource type in the target directory. mdYamlPath := filepath.Join(directory, "massdriver.yaml") if _, statErr := os.Stat(mdYamlPath); statErr == nil && !force { fmt.Printf("Resource type already exists at %s. Continuing will overwrite its contents. Continue? (y/N): ", mdYamlPath) @@ -314,16 +313,12 @@ func runTypeDelete(cmd *cobra.Command, args []string) error { return fmt.Errorf("error initializing massdriver client: %w", err) } - // Confirm the repository exists (and surface its canonical name) before prompting. repo, getErr := mdClient.OciRepos.Get(ctx, name) if getErr != nil { return fmt.Errorf("error getting resource type: %w", getErr) } - // Fail before the confirmation prompt if the repo is immutable (has published - // versions) — no point making the user type the name for a delete that can't - // succeed. RunDelete re-checks to guard against a version being published - // during the prompt. + // RunDelete re-checks, guarding against a publish during the prompt. if len(repo.Tags) > 0 { return fmt.Errorf("resource type %s has published versions and is immutable; its repository cannot be deleted", repo.Name) } diff --git a/internal/api/api.go b/internal/api/api.go index ab7b574e..c6227f3a 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,12 +1,7 @@ -// Package api is a holding pen for GraphQL operations the massdriver-sdk-go -// doesn't expose. Today that's the single deprecated `publishResourceType` -// mutation, which backs `mass resource-type publish` for raw JSON schema files. -// -// The SDK deliberately omits it: resource types are OCI-hosted now, and the -// mutation is a transitional shim the API marks `@deprecated`. It survives here -// only so customers whose pipelines still publish raw JSON schemas keep working -// until they migrate with `mass resource-type convert`. When that mutation is -// removed server-side, delete this package. +// Package api is a holding pen for GraphQL operations the SDK doesn't expose — +// today just the deprecated `publishResourceType` mutation, kept so pipelines +// publishing raw JSON schemas keep working. Delete this package when the API +// drops the mutation. package api import ( diff --git a/internal/api/resource_type.go b/internal/api/resource_type.go index 65f1a3d1..364d6958 100644 --- a/internal/api/resource_type.go +++ b/internal/api/resource_type.go @@ -17,8 +17,6 @@ type PublishResourceTypeInput struct { Schema map[string]any `json:"schema"` } -// resourceTypeMutationResult is the wrapped payload the resource-type mutation -// returns. type resourceTypeMutationResult struct { Result *resourcetypes.ResourceType `json:"result"` Successful bool `json:"successful"` @@ -46,14 +44,9 @@ const publishResourceTypeMutation = `mutation publishResourceType($organizationI } }` -// PublishResourceType upserts a resource type from a raw JSON Schema document. -// -// It wraps the API's transitional `publishResourceType` mutation, which the -// server marks deprecated: the schema is stored as the resource type's -// unversioned `0.0.0` document, so it can't participate in versioning. It backs -// the legacy branch of `mass resource-type publish` only — the massdriver.yaml -// path publishes through OCI instead (see -// internal/commands/resourcetype.RunPublish). No new callers. +// PublishResourceType upserts a resource type from a raw JSON Schema document +// via the deprecated mutation, which stores it as the unversioned 0.0.0 +// document. No new callers — the massdriver.yaml path publishes through OCI. func PublishResourceType(ctx context.Context, mdClient *massdriver.Client, input PublishResourceTypeInput) (*resourcetypes.ResourceType, error) { cfg := mdClient.Config() diff --git a/internal/api/resource_type_test.go b/internal/api/resource_type_test.go index f73412f8..ba77b6df 100644 --- a/internal/api/resource_type_test.go +++ b/internal/api/resource_type_test.go @@ -9,9 +9,6 @@ import ( "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql/gqltest" ) -// newTestClient returns a *massdriver.Client with a resolved config (no -// credentials needed) plus the gqltest mock installed as the api package's -// transport for the duration of the test. func newTestClient(t *testing.T, responses ...gqltest.Response) (*massdriver.Client, *gqltest.Client) { t.Helper() @@ -71,9 +68,8 @@ func TestPublishResourceType(t *testing.T) { } } -// The schema arrives as a GraphQL `Map!` scalar, whose wire form is a -// JSON-encoded string rather than a nested object. Assert the encoding so a -// change to the scalar helper can't silently ship a payload the API rejects. +// The `Map!` scalar's wire form is a JSON-encoded string, not a nested object. +// Getting it wrong ships a payload the API rejects. func TestPublishResourceTypeEncodesSchemaAsScalar(t *testing.T) { mdClient, mock := newTestClient(t, gqltest.RespondWithData(map[string]any{ "publishResourceType": map[string]any{ diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index 100697ee..70ab389e 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -49,15 +49,13 @@ type Secret struct { Description string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description"` } -// Resource is one entry in a bundle's `resources` block — a resource the bundle -// produces. +// Resource is one entry in a bundle's `resources` block. type Resource struct { ResourceType string `json:"resource_type,omitempty" yaml:"resource_type,omitempty" mapstructure:"resource_type"` Required *bool `json:"required,omitempty" yaml:"required,omitempty" mapstructure:"required"` } -// Dependency is one entry in a bundle's `dependencies` block — a resource the -// bundle depends on. +// Dependency is one entry in a bundle's `dependencies` block. type Dependency struct { ResourceType string `json:"resource_type,omitempty" yaml:"resource_type,omitempty" mapstructure:"resource_type"` Required *bool `json:"required,omitempty" yaml:"required,omitempty" mapstructure:"required"` @@ -83,9 +81,7 @@ type Bundle struct { Resources map[string]Resource `json:"resources,omitempty" yaml:"resources,omitempty" mapstructure:"resources"` Dependencies map[string]Dependency `json:"dependencies,omitempty" yaml:"dependencies,omitempty" mapstructure:"dependencies"` - // dependencySchema is the canonical JSON-schema form of the bundle's - // dependencies (from Dependencies or the legacy Connections block), hydrated - // on demand and dereferenced in place by DereferenceSchemas. + // Canonical JSON-schema form of the dependencies, hydrated on demand. dependencySchema map[string]any } @@ -163,11 +159,8 @@ func parseMetadataSchema() map[string]any { return metadata } -// normalizeInputs reconciles the input blocks: the two forms of a slot -// (`connections`/`dependencies` and `artifacts`/`resources`) are mutually -// exclusive, and using a legacy `connections`/`artifacts` block warns that it's -// deprecated. It does not write into the legacy fields — the dependency schema -// is hydrated separately. +// The two forms of a slot are mutually exclusive; the legacy one warns. Legacy +// fields are not written to — the dependency schema is hydrated separately. func (b *Bundle) normalizeInputs() error { hasArtifacts := b.Artifacts != nil hasConnections := b.Connections != nil @@ -191,10 +184,8 @@ func (b *Bundle) normalizeInputs() error { return nil } -// hydrateDependencySchema builds dependencySchema — the canonical JSON-schema map -// ({properties: {name: {$ref}}, required: [...]}) that downstream code (schema -// dereferencing, provisioner input generation, lint) reads for dependencies. It -// is sourced from `dependencies` (new) or the legacy `connections` block. +// Builds the canonical dependency schema every downstream reader uses, from +// `dependencies` or the legacy `connections` block. func (b *Bundle) hydrateDependencySchema() { switch { case len(b.Dependencies) > 0: @@ -209,9 +200,7 @@ func (b *Bundle) hydrateDependencySchema() { } } -// dependenciesToSchema converts a `dependencies` map into the canonical JSON -// schema. Per-entry validation (resource_type/required presence) is handled by -// bundle schema validation, before dereferencing. +// Per-entry validation happens earlier, during bundle schema validation. func dependenciesToSchema(deps map[string]Dependency) map[string]any { properties := map[string]any{} required := []any{} diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go index c587c62b..34e3eb38 100644 --- a/internal/bundle/bundle_test.go +++ b/internal/bundle/bundle_test.go @@ -10,9 +10,7 @@ import ( func boolPtr(b bool) *bool { return &b } -// TestUnmarshalDependencyResourceVariants covers the three ways `dependencies` -// and `resources` can be "empty": missing entirely, present-but-null, and an -// empty object. All must unmarshal cleanly to an empty dependency schema. +// Missing, present-but-null, and empty-object must all unmarshal cleanly. func TestUnmarshalDependencyResourceVariants(t *testing.T) { const base = "name: example\ndescription: a bundle\nversion: 1.0.0\nsteps:\n - path: src\n provisioner: terraform\nparams:\n properties: {}\nui: {}\n" cases := map[string]string{ diff --git a/internal/bundle/publish.go b/internal/bundle/publish.go index 030b6f44..2861f244 100644 --- a/internal/bundle/publish.go +++ b/internal/bundle/publish.go @@ -11,9 +11,7 @@ import ( // ArtifactType is the OCI artifact-type media type for bundles. const ArtifactType = "application/vnd.massdriver.bundle.v1+json" -// PackageKeep returns the keep predicate used when packaging a bundle. It honors -// a bundle's optional .mdignore file, falling back to a default allowlist that -// only lets the expected bundle files through. +// PackageKeep honors an optional .mdignore, falling back to an allowlist. func PackageKeep(bundleDir string) (func(relPath string) bool, error) { ignoreMatcher, ignoreErr := getIgnores(filepath.Join(bundleDir, ".mdignore")) if ignoreErr != nil { diff --git a/internal/commands/bundle/build.go b/internal/commands/bundle/build.go index 01ad82f8..ded915dc 100644 --- a/internal/commands/bundle/build.go +++ b/internal/commands/bundle/build.go @@ -12,8 +12,7 @@ import ( "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" ) -// RunBuild validates the bundle against the Massdriver bundle schema, then builds -// it at buildPath. +// RunBuild validates the bundle, then builds it at buildPath. func RunBuild(buildPath string, b *bundle.Bundle, mdClient *massdriver.Client) error { if err := ValidateSchema(b, mdClient.Config().URL); err != nil { return err @@ -21,11 +20,8 @@ func RunBuild(buildPath string, b *bundle.Bundle, mdClient *massdriver.Client) e return b.Build(buildPath, resourcetype.NewMassdriverResolver(mdClient)) } -// ValidateSchema fetches the bundle schema from the Massdriver API and validates -// the bundle against it. It must run before dereferencing, which assumes a -// schema-valid bundle. A fetch failure or any validation error is returned so the -// caller can halt — the API is the authority on the bundle format, and a build -// that can't reach it can't generate correct inputs anyway. +// ValidateSchema must run before dereferencing, which assumes a schema-valid +// bundle. A fetch failure halts the build — the API owns the bundle format. func ValidateSchema(b *bundle.Bundle, serverURL string) error { result := b.LintSchema(serverURL) if !result.HasErrors() { diff --git a/internal/commands/bundle/publish.go b/internal/commands/bundle/publish.go index 10bf5b3d..b5400c46 100644 --- a/internal/commands/bundle/publish.go +++ b/internal/commands/bundle/publish.go @@ -7,6 +7,7 @@ import ( "time" "github.com/massdriver-cloud/mass/internal/bundle" + "github.com/massdriver-cloud/mass/internal/commands/repository" "github.com/massdriver-cloud/mass/internal/oci" "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" @@ -70,7 +71,7 @@ func RunPublish(ctx context.Context, b *bundle.Bundle, mdClient *massdriver.Clie func getVersion(ctx context.Context, mdClient *massdriver.Client, b *bundle.Bundle, developmentRelease bool) (string, error) { repo, err := mdClient.OciRepos.Get(ctx, b.Name) if err != nil { - return "", fmt.Errorf("fetching OCI repo: %w", err) + return "", repository.NotFoundHint(err, "bundle", b.Name) } tagNames := make([]string, len(repo.Tags)) for i, t := range repo.Tags { diff --git a/internal/commands/repository/artifacttype.go b/internal/commands/repository/artifacttype.go index 212e96b6..9cec682c 100644 --- a/internal/commands/repository/artifacttype.go +++ b/internal/commands/repository/artifacttype.go @@ -4,10 +4,12 @@ package repository import ( + "errors" "fmt" "sort" "strings" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/ocirepos" ) @@ -26,9 +28,11 @@ var artifactTypeLabels = map[ocirepos.ArtifactType]string{ } // ResolveArtifactType converts a user-facing alias (e.g. "bundle", -// "resource-type") into the SDK's typed enum. Matching is case-insensitive. +// "resource-type") into the SDK's typed enum. Matching is case-insensitive; +// underscores match hyphens so the SDK's own "RESOURCE_TYPE" resolves too. func ResolveArtifactType(s string) (ocirepos.ArtifactType, error) { - if at, ok := artifactTypeAliases[strings.ToLower(s)]; ok { + normalized := strings.ReplaceAll(strings.ToLower(s), "_", "-") + if at, ok := artifactTypeAliases[normalized]; ok { return at, nil } return "", fmt.Errorf("unknown artifact type %q (valid: %s)", s, strings.Join(ValidArtifactTypes(), ", ")) @@ -53,3 +57,11 @@ func ValidArtifactTypes() []string { sort.Strings(valid) return valid } + +// NotFoundHint replaces a not-found error with one naming the create command. +func NotFoundHint(err error, artifactType, name string) error { + if !errors.Is(err, gql.ErrNotFound) { + return err + } + return fmt.Errorf("%s %q does not exist. Create it with: mass %s create %s", artifactType, name, artifactType, name) +} diff --git a/internal/commands/repository/artifacttype_test.go b/internal/commands/repository/artifacttype_test.go index e63963b8..b494cf2c 100644 --- a/internal/commands/repository/artifacttype_test.go +++ b/internal/commands/repository/artifacttype_test.go @@ -1,9 +1,12 @@ package repository_test import ( + "errors" + "fmt" "testing" "github.com/massdriver-cloud/mass/internal/commands/repository" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/ocirepos" ) @@ -35,6 +38,22 @@ func TestArtifactTypeRoundTrip(t *testing.T) { } } +// The create commands pass the enum, not the alias; only accepting aliases +// broke `resource-type create` outright. +func TestResolveArtifactTypeAcceptsSDKEnum(t *testing.T) { + for _, enum := range []ocirepos.ArtifactType{ocirepos.ArtifactTypeBundle, ocirepos.ArtifactTypeResourceType} { + t.Run(string(enum), func(t *testing.T) { + got, err := repository.ResolveArtifactType(string(enum)) + if err != nil { + t.Fatalf("ResolveArtifactType(%q) returned error: %v", enum, err) + } + if got != enum { + t.Errorf("ResolveArtifactType(%q) = %q, want %q", enum, got, enum) + } + }) + } +} + func TestResolveArtifactTypeCaseInsensitive(t *testing.T) { at, err := repository.ResolveArtifactType("Resource-Type") if err != nil { @@ -64,3 +83,17 @@ func TestArtifactTypeLabelFallback(t *testing.T) { t.Errorf("ArtifactTypeLabel fallback = %q, want %q", got, "SOMETHING_NEW") } } + +func TestNotFoundHint(t *testing.T) { + notFound := fmt.Errorf("get oci repo x: %w", gql.ErrNotFound) + got := repository.NotFoundHint(notFound, "resource-type", "aws-s3-bucket") + want := `resource-type "aws-s3-bucket" does not exist. Create it with: mass resource-type create aws-s3-bucket` + if got.Error() != want { + t.Errorf("NotFoundHint = %q, want %q", got, want) + } + + other := errors.New("network unreachable") + if !errors.Is(repository.NotFoundHint(other, "bundle", "x"), other) { + t.Error("non-not-found errors must pass through unchanged") + } +} diff --git a/internal/commands/resourcetype/convert.go b/internal/commands/resourcetype/convert.go index 33d4e052..7f7b4e55 100644 --- a/internal/commands/resourcetype/convert.go +++ b/internal/commands/resourcetype/convert.go @@ -12,9 +12,7 @@ import ( "gopkg.in/yaml.v3" ) -// placeholderVersion is written into the converted massdriver.yaml since a raw -// JSON schema carries no version. The author must set a real version before -// publishing. +// A raw JSON schema carries no version; the author must set a real one. const placeholderVersion = "0.0.0" // ConvertResult describes the files a RunConvert call produced. @@ -23,11 +21,9 @@ type ConvertResult struct { ExtraFiles []string // paths to extracted instruction/export files } -// RunConvert reads a raw JSON (or YAML) resource type schema at schemaPath and -// writes an equivalent massdriver.yaml. Inlined instruction/export content is -// extracted back out to referenced files. outputPath is the massdriver.yaml to -// write; when empty it defaults to a massdriver.yaml alongside schemaPath. -// Existing files are not overwritten unless force is set. +// RunConvert writes an equivalent massdriver.yaml for the raw schema at +// schemaPath, extracting inlined instruction/export content back out to files. +// outputPath defaults to a massdriver.yaml alongside schemaPath. func RunConvert(schemaPath, outputPath string, force bool) (*ConvertResult, error) { raw, readErr := readRawSchema(schemaPath) if readErr != nil { @@ -46,7 +42,6 @@ func RunConvert(schemaPath, outputPath string, force bool) (*ConvertResult, erro return nil, fmt.Errorf("failed to marshal massdriver.yaml: %w", marshalErr) } - // Refuse to clobber anything unless forced. targets := []string{outputPath} for rel := range extraFiles { targets = append(targets, filepath.Join(outputDir, rel)) @@ -94,10 +89,8 @@ func readRawSchema(path string) (map[string]any, error) { return nil, fmt.Errorf("failed to read schema: %w", readErr) } - // JSON is valid YAML, so both go through yaml.v3. This preserves integers as - // int; encoding/json would coerce every number to float64, which corrupts - // large integers and re-emits them in scientific notation (e.g. 1000000 -> - // 1e+06) when the schema is marshalled back out. + // Both go through yaml.v3 to preserve integers as int; encoding/json coerces + // every number to float64, re-emitting 1000000 as 1e+06 on the way out. var raw map[string]any if err := yaml.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("failed to parse schema: %w", err) @@ -105,10 +98,7 @@ func readRawSchema(path string) (map[string]any, error) { return raw, nil } -// reverseBuild is the inverse of resourcetype.Build: it lifts the `$md` block -// back into the massdriver.yaml fields, extracts inlined instruction/export -// content into files keyed by their relative path, and moves the remaining keys -// under `schema`. +// reverseBuild is the inverse of resourcetype.Build. func reverseBuild(raw map[string]any) (*resourcetype.MassdriverYAML, map[string][]byte) { config := &resourcetype.MassdriverYAML{Version: placeholderVersion} extraFiles := map[string][]byte{} @@ -190,9 +180,7 @@ func reverseExports(exportsRaw []any, extraFiles map[string][]byte) []resourcety return exports } -// uniqueRel builds "/.", appending an incrementing numeric -// suffix until the path is unused, so two items that reduce to the same name -// don't clobber each other's extracted file. +// Suffixes until unused, so two items reducing to the same name don't collide. func uniqueRel(extraFiles map[string][]byte, dir, name, ext string) string { base := fmt.Sprintf("%s/%s", dir, name) rel := base + "." + ext @@ -213,8 +201,7 @@ func asString(v any) string { var nonFilenameChars = regexp.MustCompile(`[^a-z0-9]+`) -// sanitize turns a human label into a filesystem-friendly name, falling back to -// an index-based name when the label has no usable characters. +// Falls back to an index when the label has no usable characters. func sanitize(label string, index int) string { name := nonFilenameChars.ReplaceAllString(strings.ToLower(label), "-") name = strings.Trim(name, "-") diff --git a/internal/commands/resourcetype/convert_test.go b/internal/commands/resourcetype/convert_test.go index 214fc5e0..c09c4c3d 100644 --- a/internal/commands/resourcetype/convert_test.go +++ b/internal/commands/resourcetype/convert_test.go @@ -47,8 +47,7 @@ func TestRunConvert(t *testing.T) { func TestRunConvertDistinctFilesForDuplicateLabels(t *testing.T) { dir := t.TempDir() - // Labels crafted to trip the old (buggy) unique-path logic: the third - // instruction's fallback name collided with the first's. + // Crafted so the third instruction's fallback name collides with the first. raw := `{ "$md": { "name": "dup", @@ -89,10 +88,8 @@ func TestRunConvertDistinctFilesForDuplicateLabels(t *testing.T) { } } -// TestRunConvertRoundTrip converts a realistic raw schema and rebuilds it with -// resourcetype.Build, verifying that instruction/export content is extracted and -// restored and that numeric constraints survive (a regression guard for the -// json-float64 corruption that turned integers into scientific notation). +// Guards the json-float64 corruption that turned integers into scientific +// notation. func TestRunConvertRoundTrip(t *testing.T) { dir := t.TempDir() raw := `{ @@ -141,21 +138,18 @@ func TestRunConvertRoundTrip(t *testing.T) { t.Errorf("name = %v, want roundtrip", md["name"]) } - // Instruction content extracted to a file and restored on rebuild. ui, _ := md["ui"].(map[string]any) instructions, _ := ui["instructions"].([]map[string]any) if len(instructions) != 1 || instructions[0]["content"] != "step one\nstep two" { t.Errorf("instruction content not restored: %#v", instructions) } - // Export template extracted to a file and restored on rebuild. exports, _ := md["export"].([]map[string]any) if len(exports) != 1 || exports[0]["template"] != "key: {{ .val }}" { t.Errorf("export template not restored: %#v", exports) } - // Numeric fidelity: the large integer default must round-trip as an int, - // not a float rendered in scientific notation. + // Must round-trip as an int, not a float in scientific notation. props, _ := built["properties"].(map[string]any) count, _ := props["count"].(map[string]any) if d, ok := count["default"].(int); !ok || d != 1000000 { diff --git a/internal/commands/resourcetype/delete.go b/internal/commands/resourcetype/delete.go index ab3a0aa4..395b56e7 100644 --- a/internal/commands/resourcetype/delete.go +++ b/internal/commands/resourcetype/delete.go @@ -8,10 +8,8 @@ import ( "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/ocirepos" ) -// RunDelete removes a resource type's OCI repository. Because published versions -// are immutable, deletion is refused locally when the repository already has -// tags. UX (confirmation prompt, success message) is the caller's -// responsibility — see cmd.runTypeDelete. +// RunDelete removes a resource type's OCI repository, refusing locally when it +// already has tags. Prompting is the caller's responsibility. func RunDelete(ctx context.Context, mdClient *massdriver.Client, name string) (*ocirepos.OciRepo, error) { repo, getErr := mdClient.OciRepos.Get(ctx, name) if getErr != nil { diff --git a/internal/commands/resourcetype/publish.go b/internal/commands/resourcetype/publish.go index 0b31b871..54f78f58 100644 --- a/internal/commands/resourcetype/publish.go +++ b/internal/commands/resourcetype/publish.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/massdriver-cloud/mass/internal/api" + "github.com/massdriver-cloud/mass/internal/commands/repository" "github.com/massdriver-cloud/mass/internal/jsonschema" "github.com/massdriver-cloud/mass/internal/oci" "github.com/massdriver-cloud/mass/internal/prettylogs" @@ -21,10 +22,7 @@ import ( "oras.land/oras-go/v2/content/memory" ) -// allowedFiles is the exact set of top-level files that may be packaged into a -// resource type artifact. readme/changelog are listed in both their -// conventional uppercase and lowercase forms; everything else at the top level -// is silently skipped. +// Everything else at the top level is silently skipped. var allowedFiles = []string{ "massdriver.yaml", "README.md", @@ -37,8 +35,6 @@ var allowedFiles = []string{ "icon.jpeg", } -// referencedPaths returns the raw instruction and export template file -// references declared in a massdriver.yaml, in declaration order. func referencedPaths(config *resourcetype.MassdriverYAML) []string { var refs []string if config.UI != nil { @@ -52,10 +48,6 @@ func referencedPaths(config *resourcetype.MassdriverYAML) []string { return refs } -// packageKeep builds the keep predicate used when packaging a resource type. -// It admits the allowlisted top-level files plus the exact instruction and -// export template files the massdriver.yaml references (wherever they live in -// the directory tree), and silently skips everything else. func packageKeep(config *resourcetype.MassdriverYAML) func(relPath string) bool { referenced := map[string]bool{} for _, p := range referencedPaths(config) { @@ -69,11 +61,8 @@ func packageKeep(config *resourcetype.MassdriverYAML) func(relPath string) bool } } -// validateReferencedFiles ensures every instruction/export file the -// massdriver.yaml references resolves to a real file inside srcDir. References -// that are absolute, escape the directory, or don't exist would be dropped by -// the packager and produce a silently incomplete artifact, so they're rejected -// up front. +// References the packager would drop are rejected up front: they'd ship a +// silently incomplete artifact. func validateReferencedFiles(config *resourcetype.MassdriverYAML, srcDir string) error { for _, ref := range referencedPaths(config) { if ref == "" { @@ -94,11 +83,8 @@ func validateReferencedFiles(config *resourcetype.MassdriverYAML, srcDir string) return nil } -// normalizeRel converts a massdriver.yaml file reference (relative to the -// massdriver.yaml, e.g. "./instructions/cli.md") into the slash-separated, -// cleaned form the packager's keep predicate receives. Empty and non-local -// (absolute or parent-escaping) references return "" since they can't match a -// file walked under the resource type directory. +// normalizeRel renders a massdriver.yaml file reference in the form the keep +// predicate receives. Non-local references return "" — nothing can match them. func normalizeRel(p string) string { if p == "" { return "" @@ -110,10 +96,8 @@ func normalizeRel(p string) string { return cleaned } -// RunPublish validates a resource type located at path and publishes it. path -// may be a directory containing a massdriver.yaml, the massdriver.yaml itself, -// or — via the deprecated legacy path — a raw JSON/YAML schema file. It returns -// the resource type name and the published version. +// RunPublish publishes the resource type at path — a directory, a +// massdriver.yaml, or a raw schema file via the deprecated legacy path. func RunPublish(ctx context.Context, mdClient *massdriver.Client, path string) (string, string, error) { target, resolveErr := resolvePublishPath(path) if resolveErr != nil { @@ -137,14 +121,11 @@ func RunPublish(ctx context.Context, mdClient *massdriver.Client, path string) ( config.Version = "0.0.0" } - // Referenced instruction/export files must live inside the packaged - // directory, otherwise the artifact would ship incomplete. if refErr := validateReferencedFiles(config, srcDir); refErr != nil { return "", "", refErr } - // Fail fast on a duplicate version before the network-heavy schema - // dereference and validation. + // Before the network-heavy dereference and validation. if versionErr := checkDuplicateVersion(ctx, mdClient, config.Name, config.Version); versionErr != nil { return "", "", versionErr } @@ -174,22 +155,16 @@ func RunPublish(ctx context.Context, mdClient *massdriver.Client, path string) ( return config.Name, config.Version, nil } -// publishTarget is the resolved shape of a publish argument: either a -// massdriver.yaml plus the directory to package (the OCI flow), or a raw -// JSON/YAML schema file (the deprecated legacy flow). type publishTarget struct { - // path is the massdriver.yaml, or the raw schema file when legacy is set. + // The massdriver.yaml, or the raw schema file when legacy is set. path string - // srcDir is the directory packaged into the OCI artifact. Unused when - // legacy is set — the legacy mutation publishes a schema document, not a - // directory. + // Directory packaged into the OCI artifact. Unused when legacy is set. srcDir string legacy bool } -// resolvePublishPath resolves the publish target. A directory or massdriver.yaml -// takes the OCI flow; a bare .json/.yaml/.yml schema file takes the deprecated -// legacy flow. +// A directory or massdriver.yaml takes the OCI flow; a bare schema file takes +// the deprecated legacy flow. func resolvePublishPath(path string) (publishTarget, error) { info, statErr := os.Stat(path) if statErr != nil { @@ -216,14 +191,10 @@ func resolvePublishPath(path string) (publishTarget, error) { } } -// publishLegacySchema publishes a raw JSON/YAML schema document through the -// deprecated `publishResourceType` mutation. The schema has no version of its -// own, so the API stores it as the resource type's unversioned 0.0.0 document — -// which is why this flow can't participate in resource type versioning and is -// on its way out. +// The schema has no version of its own, so the API stores it as the +// unversioned 0.0.0 document — hence no versioning support. func publishLegacySchema(ctx context.Context, mdClient *massdriver.Client, path string) (string, string, error) { - // Warn before any work so the notice lands whether or not the publish - // itself succeeds. + // Before any work, so it lands even if the publish fails. warnLegacySchema(path) rt, readErr := resourcetype.Read(ctx, mdClient, path) @@ -244,25 +215,22 @@ func publishLegacySchema(ctx context.Context, mdClient *massdriver.Client, path if version == "" { version = legacySchemaVersion } - return published.Name, version, nil + + // published.Name is the human label; the identifier is the ID's prefix. + name := published.Name + if identifier, _, found := strings.Cut(published.ID, "@"); found && identifier != "" { + name = identifier + } + return name, version, nil } -// legacySchemaVersion is the unversioned document the legacy mutation writes to. -// Used only as a display fallback if the API omits the version in its response. const legacySchemaVersion = "0.0.0" -// warnLegacySchema tells the user their raw JSON schema is on a deprecated, -// unversioned path and points them at `resource-type convert`. -// Printed as separate lines rather than one multi-line string: lipgloss pads -// every line of a styled block to the width of its longest line, which leaves -// ragged trailing whitespace once a long file path is interpolated in. func warnLegacySchema(path string) { fmt.Println(prettylogs.Orange("Warning: this resource type is a raw JSON schema. That format is deprecated, does not support versioning, and will be removed in a future release. Migrate it to the massdriver.yaml format, which supports versioning, by running:")) fmt.Println(prettylogs.Orange(fmt.Sprintf(" mass resource-type convert %s", path))) } -// validateSchema builds and dereferences the resource type, then validates it -// against the resource type schema and the JSON Schema meta-schema. func validateSchema(ctx context.Context, mdClient *massdriver.Client, mdYamlPath string) error { rt, readErr := resourcetype.Read(ctx, mdClient, mdYamlPath) if readErr != nil { @@ -271,8 +239,6 @@ func validateSchema(ctx context.Context, mdClient *massdriver.Client, mdYamlPath return validateBuiltSchema(mdClient, rt) } -// validateBuiltSchema validates an already read-and-dereferenced resource type -// against the resource type schema and the JSON Schema meta-schema. func validateBuiltSchema(mdClient *massdriver.Client, rt map[string]any) error { cfg := mdClient.Config() rtSchemaURL, err := url.JoinPath(cfg.URL, "json-schemas", "resource-type.json") @@ -294,16 +260,17 @@ func validateBuiltSchema(mdClient *massdriver.Client, rt map[string]any) error { return nil } -// checkDuplicateVersion fails locally if version has already been published, -// matching the immutability the API enforces. +// checkDuplicateVersion mirrors the immutability the API enforces, failing +// before the network-heavy packaging work. func checkDuplicateVersion(ctx context.Context, mdClient *massdriver.Client, name, version string) error { - // 0.0.0 is the unversioned/dev tag — always republishable, matching bundles. + // 0.0.0 is the unversioned/dev tag — republishable, matching bundles, though + // the API refuses it once other versions exist. if version == "0.0.0" { return nil } repo, err := mdClient.OciRepos.Get(ctx, name) if err != nil { - return fmt.Errorf("fetching OCI repo: %w", err) + return repository.NotFoundHint(err, "resource-type", name) } for _, t := range repo.Tags { if t.Tag == version { diff --git a/internal/commands/resourcetype/publish_test.go b/internal/commands/resourcetype/publish_test.go index f6fd8576..7cb20cf7 100644 --- a/internal/commands/resourcetype/publish_test.go +++ b/internal/commands/resourcetype/publish_test.go @@ -12,11 +12,7 @@ import ( "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql/gqltest" ) -// TestRunPublishValidation covers the local validation RunPublish performs -// before it touches the OCI registry: rejecting unsupported file types and -// requiring a name in the massdriver.yaml. These paths short-circuit before the -// massdriver client is used, so a nil client is fine. (A missing version is not -// an error — it warns and defaults to 0.0.0.) +// These paths short-circuit before the client is used, so a nil client is fine. func TestRunPublishValidation(t *testing.T) { dir := t.TempDir() @@ -53,10 +49,8 @@ func TestRunPublishValidation(t *testing.T) { } } -// schemaDir writes the two json-schemas documents RunPublish validates -// against and returns a file:// base URL pointing at them. The schema loader -// handles file:// the same as https://, so this exercises the real validation -// path without standing up an HTTP server. +// schemaDir returns a file:// base URL serving the json-schemas RunPublish +// validates against. The loader treats file:// like https://. func schemaDir(t *testing.T, resourceTypeSchema map[string]any) string { t.Helper() @@ -79,8 +73,6 @@ func schemaDir(t *testing.T, resourceTypeSchema map[string]any) string { return "file://" + dir } -// legacyClient wires a client at the schema directory with the gql mock -// installed as both the SDK and api-package transport. func legacyClient(t *testing.T, baseURL string, responses ...gqltest.Response) *massdriver.Client { t.Helper() @@ -98,9 +90,6 @@ func legacyClient(t *testing.T, baseURL string, responses ...gqltest.Response) * return mdClient } -// captureStdout runs fn with os.Stdout pointed at a temp file and returns what -// it printed. The deprecation notice is the whole point of the legacy path, so -// it has to be asserted on. func captureStdout(t *testing.T, fn func()) string { t.Helper() @@ -138,10 +127,7 @@ const legacySchemaJSON = `{ "properties": {"arn": {"type": "string"}} }` -// TestRunPublishLegacySchema covers the deprecated raw-schema flow end to end: -// it must route away from OCI, validate, publish through the legacy mutation, -// and warn the user. This path was dropped and re-added once already (commit -// 1e16b3b), so it is pinned here. +// The legacy path was dropped and re-added once already (1e16b3b). func TestRunPublishLegacySchema(t *testing.T) { path := writeSchema(t, "aws-iam-role.json", legacySchemaJSON) @@ -149,9 +135,10 @@ func TestRunPublishLegacySchema(t *testing.T) { "publishResourceType": map[string]any{ "successful": true, "messages": []any{}, + // The API returns the label in `name`, the identifier in `id`. "result": map[string]any{ "id": "aws-iam-role@0.0.0", - "name": "aws-iam-role", + "name": "AWS IAM Role", "version": "0.0.0", }, }, @@ -167,10 +154,8 @@ func TestRunPublishLegacySchema(t *testing.T) { t.Fatalf("RunPublish returned an error: %v", publishErr) } if name != "aws-iam-role" { - t.Errorf("name = %q, want aws-iam-role", name) + t.Errorf("name = %q, want aws-iam-role (the identifier, not the label)", name) } - // A raw schema has no version of its own; the API stores it as the - // unversioned 0.0.0 document. if version != "0.0.0" { t.Errorf("version = %q, want 0.0.0", version) } @@ -182,9 +167,7 @@ func TestRunPublishLegacySchema(t *testing.T) { } } -// repoWithTags builds a client whose OciRepos.Get returns a repository carrying -// the given published tags. Get selects tags through a paginated `items` -// envelope, which is the shape the SDK unwraps. +// Get returns tags through a paginated `items` envelope. func repoWithTags(t *testing.T, name string, tags ...string) *massdriver.Client { t.Helper() @@ -215,9 +198,6 @@ func newMockClient(t *testing.T, responses ...gqltest.Response) *massdriver.Clie return mdClient } -// TestCheckDuplicateVersion pins the local half of publish immutability. Too -// strict and valid publishes are blocked before they reach the API; too loose -// and the user only learns of the collision after the packaging work. func TestCheckDuplicateVersion(t *testing.T) { tests := []struct { name string @@ -245,10 +225,10 @@ func TestCheckDuplicateVersion(t *testing.T) { wantPass: true, }, { - name: "api failure is surfaced", + name: "unrelated api failure passes through", client: func(t *testing.T) *massdriver.Client { return newMockClient(t, gqltest.RespondWithError("boom")) }, version: "1.0.0", - wantErr: "fetching OCI repo", + wantErr: "boom", }, } @@ -271,11 +251,9 @@ func TestCheckDuplicateVersion(t *testing.T) { } } -// TestCheckDuplicateVersionDevTagSkipsAPI pins that 0.0.0 is always -// republishable. The nil client is the assertion: if the carve-out ever stops -// short-circuiting, this panics rather than silently costing a round trip. +// The nil client is the assertion: losing the short-circuit panics here. func TestCheckDuplicateVersionDevTagSkipsAPI(t *testing.T) { if err := checkDuplicateVersion(t.Context(), nil, "aws-s3-bucket", "0.0.0"); err != nil { - t.Fatalf("0.0.0 should always be republishable, got: %v", err) + t.Fatalf("0.0.0 should skip the duplicate check, got: %v", err) } } diff --git a/internal/commands/resourcetype/pull.go b/internal/commands/resourcetype/pull.go index f819c29c..92d45007 100644 --- a/internal/commands/resourcetype/pull.go +++ b/internal/commands/resourcetype/pull.go @@ -9,9 +9,7 @@ import ( "oras.land/oras-go/v2/content/file" ) -// RunPull downloads a resource type from its OCI repository into directory, -// resolving version to a concrete tag. It returns the resolved tag and the -// pulled manifest digest. +// RunPull returns the resolved tag and the pulled manifest digest. func RunPull(ctx context.Context, mdClient *massdriver.Client, name, version, directory string) (string, string, error) { repo, repoErr := mdClient.OciRepos.Target(name) if repoErr != nil { @@ -36,14 +34,12 @@ func RunPull(ctx context.Context, mdClient *massdriver.Client, name, version, di descriptor, pullErr := puller.Pull(ctx, tag) if pullErr != nil { - return "", "", fmt.Errorf("failed to pull resource type: %w", pullErr) + return "", "", fmt.Errorf("failed to pull resource type (legacy raw-schema resource types can't be pulled): %w", pullErr) } return tag, descriptor.Digest.String(), nil } -// resolveTag maps a user-supplied version (a concrete tag, a release channel -// name, or "latest") to a concrete OCI tag. func resolveTag(ctx context.Context, mdClient *massdriver.Client, name, version string) (string, error) { repo, getErr := mdClient.OciRepos.Get(ctx, name) if getErr != nil { @@ -51,8 +47,7 @@ func resolveTag(ctx context.Context, mdClient *massdriver.Client, name, version } if version == "" || version == "latest" { - // Prefer the "latest" release channel; otherwise fall back to the newest - // tag (the Get query returns tags sorted by version, descending). + // Get returns tags sorted by version, descending. if repo.LatestTag != "" { return repo.LatestTag, nil } diff --git a/internal/commands/resourcetype/resolve_test.go b/internal/commands/resourcetype/resolve_test.go index 4a4cdb73..4def333f 100644 --- a/internal/commands/resourcetype/resolve_test.go +++ b/internal/commands/resourcetype/resolve_test.go @@ -7,10 +7,8 @@ import ( "testing" ) -// TestResolvePublishPath pins which publish argument shapes take the OCI flow -// and which fall back to the deprecated raw-schema flow. Getting this wrong -// either breaks customers still publishing JSON schemas or silently pushes a -// massdriver.yaml through the unversioned legacy mutation. +// Getting this wrong either breaks customers still publishing JSON schemas or +// pushes a massdriver.yaml through the unversioned legacy mutation. func TestResolvePublishPath(t *testing.T) { dir := t.TempDir() write := func(name string) string { diff --git a/internal/oci/oci.go b/internal/oci/oci.go index 4e4f989a..7bc10ffd 100644 --- a/internal/oci/oci.go +++ b/internal/oci/oci.go @@ -1,8 +1,6 @@ -// Package oci contains the raw OCI packaging, publishing, and pulling logic -// shared by bundles and resource types. Callers supply the artifact-type media -// type and a per-file keep predicate; everything else (walking the directory, -// pushing layers, packing the manifest, copying to/from the remote repo) is -// identical across artifact kinds and lives here. +// Package oci holds the OCI packaging, publishing, and pulling logic shared by +// bundles and resource types. Callers supply the artifact-type media type and a +// per-file keep predicate; the rest is identical across artifact kinds. package oci import ( @@ -17,24 +15,21 @@ import ( "oras.land/oras-go/v2/content" ) -// Publisher packages a local directory into an OCI store and pushes it to a -// remote repository. +// Publisher packages a local directory and pushes it to a remote repository. type Publisher struct { Store oras.Target Repo oras.Target } -// Publish copies the packaged manifest from the local store to the remote -// repository under tag. +// Publish copies the packaged manifest to the remote repository under tag. func (p *Publisher) Publish(ctx context.Context, tag string) error { _, copyErr := oras.Copy(ctx, p.Store, tag, p.Repo, tag, oras.DefaultCopyOptions) return copyErr } -// Package walks srcDir and pushes every file for which keep returns true into -// the store, then packs and tags a manifest of the given artifactType. A nil -// keep predicate includes every file. Paths passed to keep are slash-separated -// and relative to srcDir. +// Package pushes every file in srcDir for which keep returns true, then packs +// and tags a manifest. A nil keep includes everything. Paths passed to keep are +// slash-separated and relative to srcDir. func (p *Publisher) Package(ctx context.Context, srcDir, tag, artifactType string, keep func(relPath string) bool) (ocispec.Descriptor, error) { var layers []ocispec.Descriptor pushedDigests := make(map[string]string) @@ -89,7 +84,7 @@ type Puller struct { Repo oras.Target } -// Pull copies the artifact at tag from the remote repository into the target. +// Pull copies the artifact at tag into the target. func (p *Puller) Pull(ctx context.Context, tag string) (ocispec.Descriptor, error) { return oras.Copy(ctx, p.Repo, tag, p.Target, tag, oras.DefaultCopyOptions) } @@ -117,8 +112,8 @@ func addFileToStore(ctx context.Context, store content.Pusher, filePath, relativ return &descriptor, nil } -// MimeTypeFromExtension returns the media type for a file extension (including -// the leading dot), or the empty string when unknown. +// MimeTypeFromExtension maps an extension (with leading dot) to a media type, +// or "" when unknown. func MimeTypeFromExtension(ext string) string { if mimeType, exists := mimeTypesFromExt[ext]; exists { return mimeType diff --git a/internal/oci/publish_test.go b/internal/oci/publish_test.go index 227c22d2..7a5cb0f4 100644 --- a/internal/oci/publish_test.go +++ b/internal/oci/publish_test.go @@ -15,8 +15,6 @@ import ( "oras.land/oras-go/v2/content/memory" ) -// writeTree materializes a path->contents map under a fresh temp dir, -// creating parent directories as needed. func writeTree(t *testing.T, files map[string]string) string { t.Helper() dir := t.TempDir() @@ -32,7 +30,6 @@ func writeTree(t *testing.T, files map[string]string) string { return dir } -// fetchManifest resolves a descriptor into a decoded OCI manifest. func fetchManifest(t *testing.T, ctx context.Context, store oras.Target, desc ocispec.Descriptor) ocispec.Manifest { t.Helper() rc, err := store.Fetch(ctx, desc) @@ -47,7 +44,6 @@ func fetchManifest(t *testing.T, ctx context.Context, store oras.Target, desc oc return manifest } -// layerTitles maps each layer's file title to its descriptor. func layerTitles(manifest ocispec.Manifest) map[string]ocispec.Descriptor { titles := map[string]ocispec.Descriptor{} for _, l := range manifest.Layers { @@ -56,9 +52,7 @@ func layerTitles(manifest ocispec.Manifest) map[string]ocispec.Descriptor { return titles } -// countingTarget records how many content layers (descriptors carrying a file -// title) were pushed, so deduplication can be asserted directly rather than -// inferred from the manifest. +// countingTarget counts pushes of file layers so dedup can be asserted directly. type countingTarget struct { oras.Target filePushes int @@ -71,9 +65,6 @@ func (c *countingTarget) Push(ctx context.Context, desc ocispec.Descriptor, r io return c.Target.Push(ctx, desc, r) } -// TestPackage covers the core packaging contract: the keep predicate decides -// what ships, nested files keep slash-separated relative titles, and the -// artifact type lands on the manifest under the requested tag. func TestPackage(t *testing.T) { srcDir := writeTree(t, map[string]string{ "massdriver.yaml": "name: aws-s3-bucket\nversion: 1.0.0\n", @@ -130,7 +121,6 @@ func TestPackage(t *testing.T) { t.Errorf("massdriver.yaml MediaType = %q, want application/yaml", got) } - // The manifest must be reachable by the tag Package assigned. resolved, resolveErr := store.Resolve(t.Context(), "1.0.0") if resolveErr != nil { t.Fatalf("resolving tag: %v", resolveErr) @@ -140,8 +130,6 @@ func TestPackage(t *testing.T) { } } -// TestPackageNilKeep pins that a nil predicate means "include everything", -// which is the documented contract for callers that don't filter. func TestPackageNilKeep(t *testing.T) { srcDir := writeTree(t, map[string]string{ "massdriver.yaml": "name: test\n", @@ -164,10 +152,8 @@ func TestPackageNilKeep(t *testing.T) { } } -// TestPackageDeduplicatesIdenticalContent pins the blob-reuse behavior: two -// files with identical bytes are pushed once but still produce two manifest -// layers, so both paths unpack on pull. A regression here either bloats the -// artifact or silently drops one of the files. +// Identical bytes are pushed once but still get a layer each, so both paths +// unpack on pull. Breaking this bloats the artifact or drops a file. func TestPackageDeduplicatesIdenticalContent(t *testing.T) { srcDir := writeTree(t, map[string]string{ "icon.svg": "", @@ -197,9 +183,7 @@ func TestPackageDeduplicatesIdenticalContent(t *testing.T) { } } -// TestPackageExtensionlessFile documents what happens to files the mime table -// doesn't cover (LICENSE, Dockerfile). They still ship; only the media type is -// empty. +// Files the mime table doesn't cover still ship, with an empty media type. func TestPackageExtensionlessFile(t *testing.T) { srcDir := writeTree(t, map[string]string{"LICENSE": "MIT"}) @@ -217,8 +201,6 @@ func TestPackageExtensionlessFile(t *testing.T) { } } -// TestPackageMissingSourceDir ensures a bad source path surfaces as an error -// rather than an empty, successfully-published artifact. func TestPackageMissingSourceDir(t *testing.T) { publisher := &oci.Publisher{Store: memory.New()} _, err := publisher.Package(t.Context(), filepath.Join(t.TempDir(), "nope"), "1.0.0", "application/vnd.massdriver.bundle.v1+json", nil) @@ -227,8 +209,6 @@ func TestPackageMissingSourceDir(t *testing.T) { } } -// TestPublish covers the store->repo copy, including that layer content -// survives the round trip. func TestPublish(t *testing.T) { srcDir := writeTree(t, map[string]string{"massdriver.yaml": "name: aws-s3-bucket\n"}) @@ -263,8 +243,6 @@ func TestPublish(t *testing.T) { } } -// TestPublishUntaggedManifest guards the ordering contract: publishing a tag -// that was never packaged must fail rather than push a partial artifact. func TestPublishUntaggedManifest(t *testing.T) { publisher := &oci.Publisher{Store: memory.New(), Repo: memory.New()} if err := publisher.Publish(t.Context(), "1.0.0"); err == nil { @@ -284,7 +262,6 @@ func TestMimeTypeFromExtension(t *testing.T) { {ext: ".tf", want: "application/hcl"}, {ext: ".svg", want: "image/svg+xml"}, {ext: ".png", want: "image/png"}, - // Unknown and extensionless inputs fall back to the empty string. {ext: ".xyz", want: ""}, {ext: "", want: ""}, } diff --git a/internal/resourcetype/build.go b/internal/resourcetype/build.go index 0ab23e0f..e1b8666a 100644 --- a/internal/resourcetype/build.go +++ b/internal/resourcetype/build.go @@ -45,8 +45,7 @@ type ExportConfig struct { TemplateLang string `yaml:"templateLang"` } -// ReadConfig reads and parses a massdriver.yaml resource type file into its -// structured form without dereferencing or building the schema. +// ReadConfig parses a massdriver.yaml without dereferencing or building it. func ReadConfig(path string) (*MassdriverYAML, error) { content, err := os.ReadFile(path) if err != nil { @@ -61,8 +60,7 @@ func ReadConfig(path string) (*MassdriverYAML, error) { return &config, nil } -// Build reads a massdriver.yaml file and builds it into the resource type -// format expected by the Massdriver API. +// Build converts a massdriver.yaml into the format the API expects. func Build(path string) (map[string]any, error) { config, err := ReadConfig(path) if err != nil { diff --git a/internal/resourcetype/dereference.go b/internal/resourcetype/dereference.go index 46721ca8..f5a27f3e 100644 --- a/internal/resourcetype/dereference.go +++ b/internal/resourcetype/dereference.go @@ -37,10 +37,8 @@ func NewMassdriverResolver(c *massdriver.Client) func(context.Context, string) ( // relativeFilePathPattern only accepts relative file path prefixes "./" and "../" var relativeFilePathPattern = regexp.MustCompile(`^(\.\/|\.\.\/)`) -// massdriverResourceTypePattern matches a resource-type ref, optionally -// namespaced (owner/name) and optionally version-pinned. The version accepts -// semver (@1.2.3), channels (@~1, @~1.2), and named releases (@latest, -// @latest+dev). The full string is passed through to the resolver. +// Matches a resource-type ref: optionally namespaced, optionally version-pinned +// with semver (@1.2.3), a channel (@~1.2), or a named release (@latest+dev). var massdriverResourceTypePattern = regexp.MustCompile(`^[a-zA-Z0-9-]+(\/[a-zA-Z0-9-]+)?(@[a-zA-Z0-9._~+-]+)?$`) var httpPattern = regexp.MustCompile(`^(http|https)://`) var fragmentPattern = regexp.MustCompile(`^#`) diff --git a/internal/resourcetype/get.go b/internal/resourcetype/get.go index b1dce7bd..b4e6df90 100644 --- a/internal/resourcetype/get.go +++ b/internal/resourcetype/get.go @@ -1,5 +1,4 @@ -// Package resourcetype provides CLI helpers around resource-type operations, -// thin wrappers over the Massdriver SDK's resource-type and OCI-repo services. +// Package resourcetype wraps the SDK's resource-type and OCI-repo services. package resourcetype import ( @@ -12,12 +11,10 @@ import ( "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" ) -// ResourceType is an alias of the SDK's resource-type record so consumers stay -// decoupled from the SDK import path. +// ResourceType aliases the SDK record so consumers skip the SDK import path. type ResourceType = resourcetypes.ResourceType -// Get retrieves a resource type by name (optionally `name@version`) from -// Massdriver, including its resolved JSON schema. +// Get retrieves a resource type by name (optionally `name@version`). func Get(ctx context.Context, mdClient *massdriver.Client, resourceTypeName string) (*ResourceType, error) { return mdClient.ResourceTypes.Get(ctx, resourceTypeName) } @@ -39,10 +36,7 @@ func GetAsMap(ctx context.Context, mdClient *massdriver.Client, resourceTypeName return result, unmarshalErr } -// List returns every resource type in the configured organization, sourced from -// the OCI repository catalog filtered to resource-type artifacts. The returned -// records carry only catalog metadata (ID, name, icon, timestamps); use [Get] -// to fetch a single resource type's schema. +// List returns catalog metadata only (no schema); use [Get] for one type. func List(ctx context.Context, mdClient *massdriver.Client) ([]ResourceType, error) { seq := mdClient.OciRepos.Iter(ctx, ocirepos.ListInput{ ArtifactType: ocirepos.ArtifactTypeResourceType, diff --git a/internal/resourcetype/get_test.go b/internal/resourcetype/get_test.go index 6a22239d..0055d0c5 100644 --- a/internal/resourcetype/get_test.go +++ b/internal/resourcetype/get_test.go @@ -50,8 +50,6 @@ func TestGet(t *testing.T) { } } -// TestList verifies List sources from the OCI-repo catalog filtered to -// resource-type artifacts and maps each repo into a ResourceType. func TestList(t *testing.T) { mdClient := newMockClient(t, gqltest.RespondWithData(map[string]any{ "ociRepos": map[string]any{ From 40adf885ad521e21b2e21042b40e347d3c56fedc Mon Sep 17 00:00:00 2001 From: chrisghill Date: Tue, 1 Sep 2026 22:46:53 -0600 Subject: [PATCH 18/19] bump SDK version --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 76b2e9ef..07dd96e8 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/BurntSushi/toml v1.5.0 + github.com/Khan/genqlient v0.8.1 github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.2.3 github.com/charmbracelet/glamour v1.0.0 @@ -14,7 +15,7 @@ require ( github.com/itchyny/gojq v0.12.16 github.com/manifoldco/promptui v0.9.0 github.com/massdriver-cloud/airlock v0.0.10 - github.com/massdriver-cloud/massdriver-sdk-go v0.2.19 + github.com/massdriver-cloud/massdriver-sdk-go v0.3.2 github.com/mattn/go-runewidth v0.0.24 github.com/opencontainers/image-spec v1.1.1 github.com/osteele/liquid v1.7.0 @@ -35,7 +36,6 @@ require ( require ( github.com/Checkmarx/kics/v2 v2.1.20 // indirect - github.com/Khan/genqlient v0.8.1 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/alecthomas/chroma/v2 v2.26.1 // indirect diff --git a/go.sum b/go.sum index 4e35a2ab..56a26bec 100644 --- a/go.sum +++ b/go.sum @@ -141,8 +141,8 @@ github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYt github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/massdriver-cloud/airlock v0.0.10 h1:05wz7kovH09X1VMfHcjLWylYanoLFcxuD0oZi13WG9U= github.com/massdriver-cloud/airlock v0.0.10/go.mod h1:igJm33JvINiUtbyEspUeKUWyWewG+jYyxO1UDHqLp9Q= -github.com/massdriver-cloud/massdriver-sdk-go v0.2.19 h1:4p9+wexriVdfO6yC2bVEOOSohROXMup0ATx4f0tupVY= -github.com/massdriver-cloud/massdriver-sdk-go v0.2.19/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= +github.com/massdriver-cloud/massdriver-sdk-go v0.3.2 h1:ydloDF6jJEk7Ptic87MlHnnZGMX/ClO8feDQ/1UX/Xs= +github.com/massdriver-cloud/massdriver-sdk-go v0.3.2/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= github.com/massdriver-cloud/terraform-config-inspect v0.0.2 h1:Jc7BrhFHLbK7Epig6ShiEVMzQPPHVIOx0/BatvtEwtY= github.com/massdriver-cloud/terraform-config-inspect v0.0.2/go.mod h1:3AbDpWxIRMdMAg7FDmTJuVBhCGNwdm49cBIOmUHjqRg= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= From 1016688e6d5cdb7e48d50828272ac55c923c28f1 Mon Sep 17 00:00:00 2001 From: chrisghill Date: Tue, 1 Sep 2026 22:55:25 -0600 Subject: [PATCH 19/19] fix devcontainer linting --- .devcontainer/devcontainer.json | 6 ++++-- .golangci.yaml | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8f9185ed..6058c5af 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,8 +8,10 @@ "seccomp=unconfined" ], "features": { - // Pins golangci-lint to match version expected by .golangci.yaml - "ghcr.io/guiyomh/features/golangci-lint:0": {} + // Keep in lockstep with the version CI runs (.github/workflows/lint.yaml). + "ghcr.io/guiyomh/features/golangci-lint:0": { + "version": "2.12" + } }, "customizations": { "vscode": { diff --git a/.golangci.yaml b/.golangci.yaml index cf8a3043..18fdb6bc 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,7 +1,8 @@ # This file is licensed under the terms of the MIT license https://opensource.org/license/mit # Copyright (c) 2021-2025 Marat Reymers -## Golden config for golangci-lint v2.1.6 +## Golden config for golangci-lint v2.1.6, run against v2.12 (see +## .github/workflows/lint.yaml and .devcontainer/devcontainer.json). # # This is the best config for golangci-lint based on my experience and opinion. # It is very strict, but not extremely strict.