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/.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 7027461c..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. @@ -416,10 +417,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/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/bundle.go b/cmd/bundle.go index 91a7a6fa..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", @@ -397,12 +396,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 +456,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(): @@ -482,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 b5b47144..b4be11d6 100644 --- a/cmd/resource_type.go +++ b/cmd/resource_type.go @@ -6,17 +6,22 @@ import ( "context" "embed" "encoding/json" + "errors" "fmt" "os" + "path/filepath" "strings" "text/template" "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" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/ocirepos" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" "github.com/spf13/cobra" ) @@ -32,6 +37,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 +55,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 +67,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"), + Example: `mass resource-type publish ./my-resource-type`, + 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.") typeDeleteCmd := &cobra.Command{ Use: "delete [resource-type]", @@ -67,14 +95,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 +142,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 +164,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 +187,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 +198,60 @@ 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 := cmdresourcetype.RunPublish(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] + 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") + cmd.SilenceUsage = true + + 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 := cmdresourcetype.RunPull(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 } @@ -149,24 +269,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) } @@ -174,6 +298,78 @@ 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) + } + + repo, getErr := mdClient.OciRepos.Get(ctx, name) + if getErr != nil { + return fmt.Errorf("error getting resource type: %w", getErr) + } + + // 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) + } + + 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 := cmdresourcetype.RunDelete(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 := cmdresourcetype.RunConvert(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 +414,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_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.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..7ed10fe0 100644 --- a/docs/generated/mass_resource-type_publish.md +++ b/docs/generated/mass_resource-type_publish.md @@ -12,27 +12,65 @@ 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. ## Usage ```bash -mass resource-type publish +mass resource-type publish [path] ``` +`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 ```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 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 +``` -# Publish a resource type from a YAML file -mass resource-type publish my-resource-type.yaml +## 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 [resource-type file] [flags] +mass resource-type publish ./my-resource-type ``` ### 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..f7b806d3 --- /dev/null +++ b/docs/generated/mass_resource-type_pull.md @@ -0,0 +1,52 @@ +--- +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] +``` + +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 +# 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@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 +``` + +### 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..9da76e58 100644 --- a/docs/helpdocs/type/publish.md +++ b/docs/helpdocs/type/publish.md @@ -1,19 +1,51 @@ # 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. ## Usage ```bash -mass resource-type publish +mass resource-type publish [path] ``` +`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 ```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 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) + +`path` may also point at a raw JSON (or YAML) schema file, the format that +predates `massdriver.yaml`: -# Publish a resource type from a YAML file -mass resource-type publish my-resource-type.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/docs/helpdocs/type/pull.md b/docs/helpdocs/type/pull.md new file mode 100644 index 00000000..23a63621 --- /dev/null +++ b/docs/helpdocs/type/pull.md @@ -0,0 +1,23 @@ +# Pull Resource Type + +Pulls a published resource type from your organization's catalog into a local +directory. + +## Usage + +```bash +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 +# 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@1.2.0 --directory ./out +``` diff --git a/go.mod b/go.mod index ca1e1782..07dd96e8 100644 --- a/go.mod +++ b/go.mod @@ -15,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.15 + 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 diff --git a/go.sum b/go.sum index eec6467f..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.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.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= diff --git a/internal/api/api.go b/internal/api/api.go index 4e95c7a0..c6227f3a 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,7 +1,7 @@ -// 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 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 52b34098..364d6958 100644 --- a/internal/api/resource_type.go +++ b/internal/api/resource_type.go @@ -3,80 +3,32 @@ package api import ( "context" "encoding/json" + "errors" "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" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/resourcetypes" ) -// 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"` + Result *resourcetypes.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 + version icon connectionOrientation schema @@ -92,101 +44,10 @@ const publishResourceTypeMutation = `mutation publishResourceType($organizationI } }` -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) { +// 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() // The schema field is a GraphQL `Map!` scalar — wire format is a @@ -208,34 +69,14 @@ func PublishResourceType(ctx context.Context, mdClient *massdriver.Client, input "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 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) } - 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"` + if resp.PublishResourceType.Result == nil { + return nil, errors.New("publish resource type: server reported success but returned no resource type") } - 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 + 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..ba77b6df --- /dev/null +++ b/internal/api/resource_type_test.go @@ -0,0 +1,140 @@ +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" +) + +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 `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{ + "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/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..70ab389e 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,33 @@ 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. +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. +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 +75,14 @@ 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. + 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"` + // Canonical JSON-schema form of the dependencies, hydrated on demand. + dependencySchema map[string]any } // Unmarshal reads and parses the massdriver.yaml file from the given directory into a Bundle. @@ -87,21 +108,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 +158,57 @@ func parseMetadataSchema() map[string]any { return metadata } + +// 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 + 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 { + fmt.Println(prettylogs.Orange("Warning: the 'connections' field is deprecated; migrate to 'dependencies'. The legacy term does not support versioned resource types")) + } + if hasArtifacts { + 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 +} + +// 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: + 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{}} + } +} + +// Per-entry validation happens earlier, during bundle schema validation. +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..34e3eb38 --- /dev/null +++ b/internal/bundle/bundle_test.go @@ -0,0 +1,147 @@ +package bundle //nolint:testpackage // exercises unexported normalizeInputs/dependencySchema + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func boolPtr(b bool) *bool { return &b } + +// 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{ + "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 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{}}} + if err := b.normalizeInputs(); err != nil { + t.Fatalf("legacy connections should be accepted (with a warning), got: %v", err) + } + }) + + 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{}}} + if err := b.normalizeInputs(); err != nil { + t.Fatalf("legacy artifacts should be accepted (with a warning), 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 297b59c4..2861f244 100644 --- a/internal/bundle/publish.go +++ b/internal/bundle/publish.go @@ -1,107 +1,25 @@ 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 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 { - 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 @@ -116,10 +34,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) "!/*/", @@ -154,71 +68,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..e031c9fd 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" ) @@ -22,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"}, }, }, } @@ -38,14 +35,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/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..ded915dc 100644 --- a/internal/commands/bundle/build.go +++ b/internal/commands/bundle/build.go @@ -2,13 +2,36 @@ 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, 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 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() { + 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/commands/bundle/publish.go b/internal/commands/bundle/publish.go index c3e0874e..b5400c46 100644 --- a/internal/commands/bundle/publish.go +++ b/internal/commands/bundle/publish.go @@ -7,6 +7,8 @@ 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" @@ -30,14 +32,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 +52,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) } @@ -64,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/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/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 new file mode 100644 index 00000000..7f7b4e55 --- /dev/null +++ b/internal/commands/resourcetype/convert.go @@ -0,0 +1,212 @@ +package resourcetype + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/massdriver-cloud/mass/internal/resourcetype" + "gopkg.in/yaml.v3" +) + +// 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. +type ConvertResult struct { + MassdriverYAML string // path to the written massdriver.yaml + ExtraFiles []string // paths to extracted instruction/export files +} + +// 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 { + 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) + } + + 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) { + 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) + } + + // 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) + } + return raw, nil +} + +// 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{} + + 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) *resourcetype.UIConfig { + ui := &resourcetype.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", sanitize(label, i), "md") + extraFiles[rel] = []byte(asString(inst["content"])) + ui.Instructions = append(ui.Instructions, resourcetype.InstructionConfig{ + Label: label, + Path: "./" + rel, + }) + } + return ui +} + +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 { + continue + } + lang := asString(exp["templateLang"]) + ext := lang + if ext == "" { + ext = "tmpl" + } + rel := uniqueRel(extraFiles, "exports", sanitize(asString(exp["downloadButtonText"]), i), ext) + extraFiles[rel] = []byte(asString(exp["template"])) + exports = append(exports, resourcetype.ExportConfig{ + DownloadButtonText: asString(exp["downloadButtonText"]), + FileFormat: asString(exp["fileFormat"]), + TemplatePath: "./" + rel, + TemplateLang: lang, + }) + } + return exports +} + +// 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 + for n := 2; ; n++ { + if _, taken := extraFiles[rel]; !taken { + return rel + } + rel = fmt.Sprintf("%s-%d.%s", base, n, ext) + } +} + +func asString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +var nonFilenameChars = regexp.MustCompile(`[^a-z0-9]+`) + +// 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, "-") + if name == "" { + return strconv.Itoa(index + 1) + } + return name +} diff --git a/internal/commands/resourcetype/convert_test.go b/internal/commands/resourcetype/convert_test.go new file mode 100644 index 00000000..c09c4c3d --- /dev/null +++ b/internal/commands/resourcetype/convert_test.go @@ -0,0 +1,202 @@ +package resourcetype_test + +import ( + "os" + "path/filepath" + "testing" + + cmdresourcetype "github.com/massdriver-cloud/mass/internal/commands/resourcetype" + rtype "github.com/massdriver-cloud/mass/internal/resourcetype" + "gopkg.in/yaml.v3" +) + +func TestRunConvert(t *testing.T) { + out := filepath.Join(t.TempDir(), "massdriver.yaml") + + result, err := cmdresourcetype.RunConvert("testdata/simple-resource.json", out, false) + if err != nil { + t.Fatalf("RunConvert 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 rtype.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") + } + 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 TestRunConvertDistinctFilesForDuplicateLabels(t *testing.T) { + dir := t.TempDir() + // Crafted so the third instruction's fallback name collides with the first. + raw := `{ + "$md": { + "name": "dup", + "ui": { "instructions": [ + { "label": "a 3", "content": "first" }, + { "label": "a", "content": "second" }, + { "label": "a", "content": "third" } + ] } + }, + "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 := cmdresourcetype.RunConvert(schemaPath, out, false) + if err != nil { + t.Fatalf("RunConvert failed: %v", err) + } + if len(result.ExtraFiles) != 3 { + t.Fatalf("expected 3 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 + } + 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) + } + } +} + +// Guards 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"]) + } + + 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) + } + + exports, _ := md["export"].([]map[string]any) + if len(exports) != 1 || exports[0]["template"] != "key: {{ .val }}" { + t.Errorf("export template not restored: %#v", exports) + } + + // 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 { + 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 { + t.Fatal(err) + } + + 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 := cmdresourcetype.RunConvert("testdata/simple-resource.json", out, true); err != nil { + t.Fatalf("expected --force to overwrite, got: %v", err) + } +} diff --git a/internal/commands/resourcetype/delete.go b/internal/commands/resourcetype/delete.go new file mode 100644 index 00000000..395b56e7 --- /dev/null +++ b/internal/commands/resourcetype/delete.go @@ -0,0 +1,24 @@ +package resourcetype + +import ( + "context" + "fmt" + + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/ocirepos" +) + +// 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 { + 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/commands/resourcetype/keep_test.go b/internal/commands/resourcetype/keep_test.go new file mode 100644 index 00000000..4530aa4a --- /dev/null +++ b/internal/commands/resourcetype/keep_test.go @@ -0,0 +1,124 @@ +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 := &rtype.MassdriverYAML{ + UI: &rtype.UIConfig{ + Instructions: []rtype.InstructionConfig{ + {Label: "CLI", Path: "./docs/cli.md"}, + {Label: "Console", Path: "instructions/console.md"}, + }, + }, + Exports: []rtype.ExportConfig{ + {DownloadButtonText: "Config", TemplatePath: "./templates/config.yaml.liquid"}, + }, + } + keep := packageKeep(config) + + admit := []string{ + "massdriver.yaml", + "README.md", + "readme.md", + "CHANGELOG.md", + "icon.svg", + "icon.png", + "icon.jpg", + "icon.jpeg", + "docs/cli.md", // referenced instruction, arbitrary dir + "instructions/console.md", // referenced instruction + "templates/config.yaml.liquid", // referenced export template + } + skip := []string{ + "main.tf", + "schema-params.json", + "icon.gif", + ".mdignore", + "docs/other.md", // unreferenced file in a referenced dir + "instructions/cli.md", // not the referenced instruction path + "secrets/key.pem", + } + + for _, f := range admit { + if !keep(f) { + t.Errorf("keep(%q) = false, want true", f) + } + } + for _, f := range skip { + if keep(f) { + t.Errorf("keep(%q) = true, want false", f) + } + } +} + +func TestPackageKeepNoReferences(t *testing.T) { + keep := packageKeep(&rtype.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) *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 := &rtype.MassdriverYAML{ + UI: uiWith("./docs/cli.md"), + Exports: []rtype.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(&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(&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(&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(&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/commands/resourcetype/publish.go b/internal/commands/resourcetype/publish.go new file mode 100644 index 00000000..54f78f58 --- /dev/null +++ b/internal/commands/resourcetype/publish.go @@ -0,0 +1,289 @@ +// 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 ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "slices" + "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" + "github.com/massdriver-cloud/mass/internal/resourcetype" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "oras.land/oras-go/v2/content/memory" +) + +// 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", +} + +func referencedPaths(config *resourcetype.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 +} + +func packageKeep(config *resourcetype.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 { + return referenced[relPath] || slices.Contains(allowedFiles, relPath) + } +} + +// 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 == "" { + 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) + } + } + return nil +} + +// 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 "" + } + cleaned := filepath.ToSlash(filepath.Clean(p)) + if cleaned == "." || filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "../") { + return "" + } + return cleaned +} + +// 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 { + 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 { + 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 == "" { + fmt.Println(prettylogs.Orange("Warning: the 'version' field in massdriver.yaml is empty. This disables all versioning capabilities.")) + config.Version = "0.0.0" + } + + if refErr := validateReferencedFiles(config, srcDir); refErr != nil { + return "", "", refErr + } + + // Before the network-heavy 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, resourcetype.ArtifactType, packageKeep(config)); 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 +} + +type publishTarget struct { + // The massdriver.yaml, or the raw schema file when legacy is set. + path string + // Directory packaged into the OCI artifact. Unused when legacy is set. + srcDir string + legacy bool +} + +// 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 { + 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 publishTarget{}, fmt.Errorf("no massdriver.yaml found in %s", path) + } + return publishTarget{path: md, srcDir: path}, nil + } + + if filepath.Base(path) == "massdriver.yaml" { + return publishTarget{path: path, srcDir: filepath.Dir(path)}, nil + } + + switch strings.ToLower(filepath.Ext(path)) { + case ".json", ".yaml", ".yml": + return publishTarget{path: path, legacy: true}, nil + default: + return publishTarget{}, fmt.Errorf("unsupported resource type path: %s (expected a directory, a massdriver.yaml, or a JSON schema file)", path) + } +} + +// 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) { + // Before any work, so it lands even if the publish fails. + 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 + } + + // 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 +} + +const legacySchemaVersion = "0.0.0" + +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))) +} + +func validateSchema(ctx context.Context, mdClient *massdriver.Client, mdYamlPath string) error { + rt, readErr := resourcetype.Read(ctx, mdClient, mdYamlPath) + if readErr != nil { + return fmt.Errorf("failed to read resource type: %w", readErr) + } + return validateBuiltSchema(mdClient, rt) +} + +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 { + return fmt.Errorf("failed to construct resource type schema URL: %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 fmt.Errorf("failed to construct meta schema URL: %w", err) + } + if validateErr := validateResourceType(rt, metaSchemaURL); validateErr != nil { + return fmt.Errorf("failed to validate resource type against meta schema: %w", validateErr) + } + + return nil +} + +// 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 — 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 repository.NotFoundHint(err, "resource-type", name) + } + 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 { + sch, loadErr := jsonschema.LoadSchemaFromURL(schemaURL) + if loadErr != nil { + return loadErr + } + return jsonschema.ValidateGo(sch, rt) +} diff --git a/internal/commands/resourcetype/publish_test.go b/internal/commands/resourcetype/publish_test.go new file mode 100644 index 00000000..7cb20cf7 --- /dev/null +++ b/internal/commands/resourcetype/publish_test.go @@ -0,0 +1,259 @@ +package resourcetype //nolint:testpackage // needs access to unexported checkDuplicateVersion + +import ( + "encoding/json" + "os" + "path/filepath" + "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" +) + +// These paths short-circuit before the client is used, so a nil client is fine. +func TestRunPublishValidation(t *testing.T) { + dir := t.TempDir() + + unsupported := filepath.Join(dir, "schema.txt") + if err := os.WriteFile(unsupported, []byte("{}"), 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) + } + emptyDir := t.TempDir() + + tests := []struct { + name string + path string + contains string + }{ + {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"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, _, err := RunPublish(t.Context(), nil, tc.path) + if err == nil { + t.Fatalf("expected an error, got nil") + } + if !strings.Contains(err.Error(), tc.contains) { + t.Fatalf("expected error to contain %q, got: %v", tc.contains, err) + } + }) + } +} + +// 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() + + 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 +} + +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 +} + +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"}} +}` + +// The legacy path was dropped and re-added once already (1e16b3b). +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{}, + // 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", + "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 (the identifier, not the label)", name) + } + 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) + } +} + +// Get returns tags through a paginated `items` envelope. +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 +} + +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: "unrelated api failure passes through", + client: func(t *testing.T) *massdriver.Client { return newMockClient(t, gqltest.RespondWithError("boom")) }, + version: "1.0.0", + wantErr: "boom", + }, + } + + 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) + } + }) + } +} + +// 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 skip the duplicate check, got: %v", err) + } +} diff --git a/internal/commands/resourcetype/pull.go b/internal/commands/resourcetype/pull.go new file mode 100644 index 00000000..92d45007 --- /dev/null +++ b/internal/commands/resourcetype/pull.go @@ -0,0 +1,73 @@ +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" +) + +// 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 { + 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 (legacy raw-schema resource types can't be pulled): %w", pullErr) + } + + return tag, descriptor.Digest.String(), nil +} + +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" { + // Get 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) +} diff --git a/internal/commands/resourcetype/resolve_test.go b/internal/commands/resourcetype/resolve_test.go new file mode 100644 index 00000000..4def333f --- /dev/null +++ b/internal/commands/resourcetype/resolve_test.go @@ -0,0 +1,79 @@ +package resourcetype //nolint:testpackage // needs access to unexported resolvePublishPath + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// 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 { + 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) + } + }) + } +} 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/oci/oci.go b/internal/oci/oci.go new file mode 100644 index 00000000..7bc10ffd --- /dev/null +++ b/internal/oci/oci.go @@ -0,0 +1,183 @@ +// 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 ( + "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 and pushes it to a remote repository. +type Publisher struct { + Store oras.Target + Repo oras.Target +} + +// 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 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) + + 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 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 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 + } + 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/oci/publish_test.go b/internal/oci/publish_test.go new file mode 100644 index 00000000..7a5cb0f4 --- /dev/null +++ b/internal/oci/publish_test.go @@ -0,0 +1,276 @@ +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" +) + +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 +} + +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 +} + +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 counts pushes of file layers so dedup can be asserted directly. +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) +} + +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) + } + + 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) + } +} + +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) + } + } +} + +// 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": "", + "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) + } +} + +// 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"}) + + 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) + } +} + +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") + } +} + +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") + } +} + +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"}, + {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) + } + }) + } +} 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..e1b8666a 100644 --- a/internal/resourcetype/build.go +++ b/internal/resourcetype/build.go @@ -9,22 +9,26 @@ 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 { 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 +45,8 @@ 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 parses a massdriver.yaml without dereferencing or building it. +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 +57,16 @@ func Build(path string) (map[string]any, error) { return nil, fmt.Errorf("failed to parse massdriver.yaml: %w", err) } + return &config, nil +} + +// 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 { + return nil, err + } + baseDir := filepath.Dir(path) // Build the $md block diff --git a/internal/resourcetype/delete.go b/internal/resourcetype/delete.go deleted file mode 100644 index 1746124e..00000000 --- a/internal/resourcetype/delete.go +++ /dev/null @@ -1,14 +0,0 @@ -package resourcetype - -import ( - "context" - - "github.com/massdriver-cloud/mass/internal/api" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" -) - -// 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) -} 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/dereference.go b/internal/resourcetype/dereference.go index 5604d640..f5a27f3e 100644 --- a/internal/resourcetype/dereference.go +++ b/internal/resourcetype/dereference.go @@ -36,7 +36,10 @@ 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-]+)?$`) + +// 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/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 diff --git a/internal/resourcetype/get.go b/internal/resourcetype/get.go index 61cb618d..b4e6df90 100644 --- a/internal/resourcetype/get.go +++ b/internal/resourcetype/get.go @@ -1,26 +1,22 @@ -// 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 wraps the 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 aliases the SDK record so consumers skip 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`). 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 +36,25 @@ func GetAsMap(ctx context.Context, mdClient *massdriver.Client, resourceTypeName return result, unmarshalErr } -// List returns every resource type in the configured organization. +// List returns catalog metadata only (no schema); use [Get] for one type. 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..0055d0c5 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,59 @@ 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"]) +} + +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) + } } } diff --git a/internal/resourcetype/publish.go b/internal/resourcetype/publish.go deleted file mode 100644 index 40aa3d48..00000000 --- a/internal/resourcetype/publish.go +++ /dev/null @@ -1,49 +0,0 @@ -package resourcetype - -import ( - "context" - "fmt" - "net/url" - - "github.com/massdriver-cloud/mass/internal/api" - "github.com/massdriver-cloud/mass/internal/jsonschema" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" -) - -// 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) - if readErr != nil { - return nil, 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) - } - err = validateResourceType(rt, rtSchemaURL) - if err != nil { - return nil, fmt.Errorf("failed to validate resource type schema: %w", err) - } - 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) - } - err = validateResourceType(rt, metaSchemaURL) - if err != nil { - return nil, fmt.Errorf("failed to validate resource type against meta schema: %w", err) - } - - return api.PublishResourceType(ctx, mdClient, api.PublishResourceTypeInput{Schema: rt}) -} - -func validateResourceType(rt map[string]any, schemaURL string) error { - sch, loadErr := jsonschema.LoadSchemaFromURL(schemaURL) - if loadErr != nil { - return loadErr - } - return jsonschema.ValidateGo(sch, rt) -} diff --git a/internal/resourcetype/publish_test.go b/internal/resourcetype/publish_test.go deleted file mode 100644 index 3d52f529..00000000 --- a/internal/resourcetype/publish_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package resourcetype_test - -import ( - "net/http" - "net/http/httptest" - "os" - "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 - } - 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", - }, - } - - 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) - } - - // 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) - } - }) - } -}