diff --git a/.golangci.yaml b/.golangci.yaml index 7027461c..cf8a3043 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -416,10 +416,6 @@ linters: # Allow unused params at the cobra command level - linters: [revive] text: "unused-parameter: parameter ('cmd'|'args') seems to be unused, consider removing or renaming it as _" - # 'api' is a domain-appropriate package name for this layer; revive flags it as "meaningless" - - linters: [revive] - text: "var-naming: avoid meaningless package names" - path: "internal/api/" - path: "_test\\.go" linters: - revive diff --git a/cmd/resource_type.go b/cmd/resource_type.go index b5b47144..7c4a2aa5 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"), + Args: cobra.MaximumNArgs(1), + RunE: runTypePublish, + } + + typePullCmd := &cobra.Command{ + Use: "pull ", + Short: "Pull a resource type from Massdriver to a local directory", + Long: helpdocs.MustRender("type/pull"), Args: cobra.ExactArgs(1), - RunE: runTypePublish, + RunE: runTypePull, } + typePullCmd.Flags().StringP("directory", "d", "", "Directory to output the resource type. Defaults to the resource type name.") + typePullCmd.Flags().BoolP("force", "f", false, "Force pull even if the directory already exists. This will overwrite existing files.") + typePullCmd.Flags().StringP("version", "v", "latest", "Resource type version or release channel") typeDeleteCmd := &cobra.Command{ Use: "delete [resource-type]", @@ -67,14 +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,56 @@ func runTypePublish(cmd *cobra.Command, args []string) error { return fmt.Errorf("error initializing massdriver client: %w", err) } - artDef, publishErr := resourcetype.Publish(ctx, mdClient, defFile) + name, version, publishErr := 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] + directory, _ := cmd.Flags().GetString("directory") + if directory == "" { + directory = name + } + force, _ := cmd.Flags().GetBool("force") + version, _ := cmd.Flags().GetString("version") + cmd.SilenceUsage = true + // Warn before overwriting an existing resource type in the target directory. + mdYamlPath := filepath.Join(directory, "massdriver.yaml") + if _, statErr := os.Stat(mdYamlPath); statErr == nil && !force { + fmt.Printf("Resource type already exists at %s. Continuing will overwrite its contents. Continue? (y/N): ", mdYamlPath) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println("Resource type pull aborted!") + return nil + } + } + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) + } + + tag, digest, pullErr := 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 +265,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 +294,82 @@ func runTypeList(cmd *cobra.Command, args []string) error { return nil } +func runTypeDelete(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + name := args[0] + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + cmd.SilenceUsage = true + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) + } + + // Confirm the repository exists (and surface its canonical name) before prompting. + repo, getErr := mdClient.OciRepos.Get(ctx, name) + if getErr != nil { + return fmt.Errorf("error getting resource type: %w", getErr) + } + + // Fail before the confirmation prompt if the repo is immutable (has published + // versions) — no point making the user type the name for a delete that can't + // succeed. RunDelete re-checks to guard against a version being published + // during the prompt. + if len(repo.Tags) > 0 { + return fmt.Errorf("resource type %s has published versions and is immutable; its repository cannot be deleted", repo.Name) + } + + 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_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..aa9e88e1 100644 --- a/docs/generated/mass_resource-type_publish.md +++ b/docs/generated/mass_resource-type_publish.md @@ -12,27 +12,39 @@ Publish a resource type to Massdriver # Publish Resource Type -Publishes a new or updated resource type to Massdriver. Supports JSON or YAML formats. +Publishes a resource type to your organization's catalog as an OCI artifact. + +The resource type is authored as a `massdriver.yaml` file, which must include a +`version` field. Publishing is immutable: a version that already exists cannot be +republished. + +Raw JSON schema publishing is no longer supported. If you have a raw JSON schema, +convert it first with `mass resource-type convert`. ## Usage ```bash -mass resource-type publish +mass resource-type publish [path] ``` +`path` is a directory containing a `massdriver.yaml` (defaults to the current +directory). Only `massdriver.yaml`, `readme`, `changelog`, icon files, and the +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 YAML file -mass resource-type publish my-resource-type.yaml +# Publish a resource type from a specific directory +mass resource-type publish ./my-resource-type ``` ``` -mass resource-type publish [resource-type file] [flags] +mass resource-type publish [path] [flags] ``` ### Options diff --git a/docs/generated/mass_resource-type_pull.md b/docs/generated/mass_resource-type_pull.md new file mode 100644 index 00000000..dbc28b73 --- /dev/null +++ b/docs/generated/mass_resource-type_pull.md @@ -0,0 +1,50 @@ +--- +id: mass_resource-type_pull.md +slug: /cli/commands/mass_resource-type_pull +title: Mass Resource-Type Pull +sidebar_label: Mass Resource-Type Pull +--- +## mass resource-type pull + +Pull a resource type from Massdriver to a local directory + +### Synopsis + +# Pull Resource Type + +Pulls a published resource type from your organization's catalog into a local +directory. + +## Usage + +```bash +mass resource-type pull [flags] +``` + +## Examples + +```bash +# Pull the latest version into a directory named after the resource type +mass resource-type pull my-resource-type + +# Pull a specific version into a specific directory +mass resource-type pull my-resource-type --version 1.2.0 --directory ./out +``` + + +``` +mass resource-type pull [flags] +``` + +### Options + +``` + -d, --directory string Directory to output the resource type. Defaults to the resource type name. + -f, --force Force pull even if the directory already exists. This will overwrite existing files. + -h, --help help for pull + -v, --version string Resource type version or release channel (default "latest") +``` + +### SEE ALSO + +* [mass resource-type](/cli/commands/mass_resource-type) - Resource type management diff --git a/docs/helpdocs/type/convert.md b/docs/helpdocs/type/convert.md new file mode 100644 index 00000000..e9940ef5 --- /dev/null +++ b/docs/helpdocs/type/convert.md @@ -0,0 +1,24 @@ +# Convert Resource Type + +Converts a raw JSON (or YAML) resource type schema into a `massdriver.yaml`. +Inlined instruction and export content is extracted back out into referenced +files alongside the generated `massdriver.yaml`. + +A placeholder `version` is written into the output — set a real version before +publishing. + +## Usage + +```bash +mass resource-type convert [flags] +``` + +## Examples + +```bash +# Convert a raw JSON schema, writing massdriver.yaml alongside it +mass resource-type convert ./my-resource-type.json + +# Convert to a specific output path, overwriting if it exists +mass resource-type convert ./my-resource-type.json --output ./rt/massdriver.yaml --force +``` diff --git a/docs/helpdocs/type/create.md b/docs/helpdocs/type/create.md new file mode 100644 index 00000000..397e2365 --- /dev/null +++ b/docs/helpdocs/type/create.md @@ -0,0 +1,21 @@ +# Create Resource Type + +Creates a new resource type OCI repository in your organization's catalog. The +repository starts empty; publish a version to it with +`mass resource-type publish`. + +## Usage + +```bash +mass resource-type create +``` + +## Examples + +```bash +# Create a resource type repository +mass resource-type create my-resource-type + +# Create with custom attributes +mass resource-type create my-resource-type -a owner=data,service=database +``` diff --git a/docs/helpdocs/type/publish.md b/docs/helpdocs/type/publish.md index dd52e9af..fda87ff2 100644 --- a/docs/helpdocs/type/publish.md +++ b/docs/helpdocs/type/publish.md @@ -1,19 +1,31 @@ # Publish Resource Type -Publishes a new or updated resource type to Massdriver. Supports JSON or YAML formats. +Publishes a resource type to your organization's catalog as an OCI artifact. + +The resource type is authored as a `massdriver.yaml` file, which must include a +`version` field. Publishing is immutable: a version that already exists cannot be +republished. + +Raw JSON schema publishing is no longer supported. If you have a raw JSON schema, +convert it first with `mass resource-type convert`. ## Usage ```bash -mass resource-type publish +mass resource-type publish [path] ``` +`path` is a directory containing a `massdriver.yaml` (defaults to the current +directory). Only `massdriver.yaml`, `readme`, `changelog`, icon files, and the +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 YAML file -mass resource-type publish my-resource-type.yaml +# Publish a resource type from a specific directory +mass resource-type publish ./my-resource-type ``` diff --git a/docs/helpdocs/type/pull.md b/docs/helpdocs/type/pull.md new file mode 100644 index 00000000..90ffff58 --- /dev/null +++ b/docs/helpdocs/type/pull.md @@ -0,0 +1,20 @@ +# Pull Resource Type + +Pulls a published resource type from your organization's catalog into a local +directory. + +## Usage + +```bash +mass resource-type pull [flags] +``` + +## Examples + +```bash +# Pull the latest version into a directory named after the resource type +mass resource-type pull my-resource-type + +# Pull a specific version into a specific directory +mass resource-type pull my-resource-type --version 1.2.0 --directory ./out +``` diff --git a/go.mod b/go.mod index ca1e1782..e78f1cb7 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.25.0 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/BurntSushi/toml v1.5.0 - github.com/Khan/genqlient v0.8.1 github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.2.3 github.com/charmbracelet/glamour v1.0.0 @@ -15,7 +14,7 @@ require ( github.com/itchyny/gojq v0.12.16 github.com/manifoldco/promptui v0.9.0 github.com/massdriver-cloud/airlock v0.0.10 - github.com/massdriver-cloud/massdriver-sdk-go v0.2.15 + github.com/massdriver-cloud/massdriver-sdk-go v0.2.18 github.com/mattn/go-runewidth v0.0.24 github.com/opencontainers/image-spec v1.1.1 github.com/osteele/liquid v1.7.0 @@ -36,6 +35,7 @@ require ( require ( github.com/Checkmarx/kics/v2 v2.1.20 // indirect + github.com/Khan/genqlient v0.8.1 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/alecthomas/chroma/v2 v2.26.1 // indirect diff --git a/go.sum b/go.sum index eec6467f..f803c10e 100644 --- a/go.sum +++ b/go.sum @@ -141,8 +141,8 @@ github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYt github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/massdriver-cloud/airlock v0.0.10 h1:05wz7kovH09X1VMfHcjLWylYanoLFcxuD0oZi13WG9U= github.com/massdriver-cloud/airlock v0.0.10/go.mod h1:igJm33JvINiUtbyEspUeKUWyWewG+jYyxO1UDHqLp9Q= -github.com/massdriver-cloud/massdriver-sdk-go v0.2.15 h1:ZJjirglHljaZqrHu8/3HeNK7RkMBHL0ZJTfPtgYlQlg= -github.com/massdriver-cloud/massdriver-sdk-go v0.2.15/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.18 h1:Z3j9qjZU2nYuEQ24LRuYLQoPxydVP6NzW6iy/T23xEg= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.18/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= github.com/massdriver-cloud/terraform-config-inspect v0.0.2 h1:Jc7BrhFHLbK7Epig6ShiEVMzQPPHVIOx0/BatvtEwtY= github.com/massdriver-cloud/terraform-config-inspect v0.0.2/go.mod h1:3AbDpWxIRMdMAg7FDmTJuVBhCGNwdm49cBIOmUHjqRg= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= diff --git a/internal/api/api.go b/internal/api/api.go deleted file mode 100644 index 4e95c7a0..00000000 --- a/internal/api/api.go +++ /dev/null @@ -1,71 +0,0 @@ -// Package api is a temporary holding pen for GraphQL operations that the -// massdriver-sdk-go doesn't expose yet. Today this is just the resource-type -// surface (Get / List / Publish / Delete). When the SDK grows native support -// the corresponding files here disappear; once the package is empty, delete it. -package api - -import ( - "errors" - "fmt" - "strings" - - "github.com/Khan/genqlient/graphql" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql" -) - -// transportOverride is set by tests to short-circuit transport construction -// (we can't reach inside *massdriver.Client to get its graphql client, so -// tests need their own injection point). Production code leaves it nil and -// gqlClient builds a real transport from the resolved config. -var transportOverride graphql.Client - -// SetTransportForTest installs a graphql.Client that every api operation will -// use instead of the configured Massdriver transport. Tests pair this with -// gqltest.NewClient and t.Cleanup to scrub on teardown. -func SetTransportForTest(c graphql.Client) func() { - transportOverride = c - return func() { transportOverride = nil } -} - -// gqlClient builds a v2-shape GraphQL client from a *massdriver.Client's -// resolved config. Each call reconstructs the transport — cheap, and avoids -// stashing state in this package. -func gqlClient(mdClient *massdriver.Client) graphql.Client { - if transportOverride != nil { - return transportOverride - } - return gql.NewV2Client(mdClient.Config()) -} - -// mutationMessage is the per-field message bag returned by GraphQL mutations. -type mutationMessage struct { - Code string `json:"code"` - Field string `json:"field"` - Message string `json:"message"` -} - -// mutationError formats one or more mutation messages into a single error -// matching the legacy CLI's user-facing output. -func mutationError(label string, messages []mutationMessage) error { - if len(messages) == 0 { - return fmt.Errorf("%s: server reported failure with no detail", label) - } - var b strings.Builder - b.WriteString(label) - b.WriteByte(':') - for _, m := range messages { - b.WriteString("\n - ") - if m.Field != "" { - b.WriteString(m.Field) - b.WriteString(": ") - } - b.WriteString(m.Message) - if m.Code != "" { - b.WriteString(" (") - b.WriteString(m.Code) - b.WriteByte(')') - } - } - return errors.New(b.String()) -} diff --git a/internal/api/resource_type.go b/internal/api/resource_type.go deleted file mode 100644 index 52b34098..00000000 --- a/internal/api/resource_type.go +++ /dev/null @@ -1,241 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/Khan/genqlient/graphql" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql/scalars" -) - -// ResourceType mirrors the v2 GraphQL schema's resource-type record. Field -// names match the JSON wire shape so handcrafted GraphQL responses decode -// without bespoke mapping. -type ResourceType struct { - ID string `json:"id"` - Name string `json:"name"` - Icon string `json:"icon,omitempty"` - ConnectionOrientation string `json:"connectionOrientation"` - Schema map[string]any `json:"schema,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// PublishResourceTypeInput is the input for PublishResourceType. -type PublishResourceTypeInput struct { - Schema map[string]any `json:"schema"` -} - -// resourceTypeMutationResult is the wrapped payload every resource-type -// mutation returns. -type resourceTypeMutationResult struct { - Result *ResourceType `json:"result"` - Successful bool `json:"successful"` - Messages []mutationMessage `json:"messages"` -} - -const getResourceTypeQuery = `query getResourceType($organizationId: ID!, $id: ID!) { - resourceType(organizationId: $organizationId, id: $id) { - id - name - icon - connectionOrientation - schema - createdAt - updatedAt - } -}` - -// resourceTypesPageSize is the per-request page size for the ListResourceTypes -// page-walk. 100 is the server's documented max, minimizing round-trips; the -// value also keeps the cursor arg non-null (see ListResourceTypes). -const resourceTypesPageSize = 100 - -const listResourceTypesQuery = `query listResourceTypes($organizationId: ID!, $cursor: Cursor) { - resourceTypes(organizationId: $organizationId, cursor: $cursor) { - items { - id - name - icon - connectionOrientation - createdAt - updatedAt - } - cursor { - next - previous - } - } -}` - -const publishResourceTypeMutation = `mutation publishResourceType($organizationId: ID!, $input: PublishResourceTypeInput!) { - publishResourceType(organizationId: $organizationId, input: $input) { - result { - id - name - icon - connectionOrientation - schema - createdAt - updatedAt - } - successful - messages { - code - field - message - } - } -}` - -const deleteResourceTypeMutation = `mutation deleteResourceType($organizationId: ID!, $id: ID!) { - deleteResourceType(organizationId: $organizationId, id: $id) { - result { - id - name - } - successful - messages { - code - field - message - } - } -}` - -// GetResourceType fetches a single resource type by name. -func GetResourceType(ctx context.Context, mdClient *massdriver.Client, name string) (*ResourceType, error) { - cfg := mdClient.Config() - var resp struct { - ResourceType *ResourceType `json:"resourceType"` - } - req := &graphql.Request{ - OpName: "getResourceType", - Query: getResourceTypeQuery, - Variables: map[string]any{ - "organizationId": cfg.OrganizationID, - "id": name, - }, - } - if err := gqlClient(mdClient).MakeRequest(ctx, req, &graphql.Response{Data: &resp}); err != nil { - return nil, fmt.Errorf("get resource type %s: %w", name, err) - } - if resp.ResourceType == nil { - return nil, fmt.Errorf("get resource type %s: %w", name, gql.ErrNotFound) - } - return resp.ResourceType, nil -} - -// ListResourceTypes fetches every resource type in the configured organization. -// The legacy CLI supported a filter argument; the few callsites that survive -// the v2 migration only need the unfiltered list. -// -// Resource types aren't in the SDK yet, so the cursor page-walk the SDK does for -// its own list endpoints is implemented here by hand: the server returns one -// page at a time, so we follow cursor.next until it's empty and accumulate every -// page. (The prior version requested only `items` with no cursor, silently -// truncating the result to the server's default first page.) -func ListResourceTypes(ctx context.Context, mdClient *massdriver.Client) ([]ResourceType, error) { - cfg := mdClient.Config() - client := gqlClient(mdClient) - - var all []ResourceType - after := "" - for { - var resp struct { - ResourceTypes struct { - Items []ResourceType `json:"items"` - Cursor struct { - Next string `json:"next"` - Previous string `json:"previous"` - } `json:"cursor"` - } `json:"resourceTypes"` - } - req := &graphql.Request{ - OpName: "listResourceTypes", - Query: listResourceTypesQuery, - Variables: map[string]any{ - "organizationId": cfg.OrganizationID, - // Always send an explicit page size: the server returns 500 on a - // `cursor: null` arg, which is what NewCursor(0, "") would - // produce on the first request. A positive limit makes NewCursor - // emit `{limit, next}` instead. `after` is the prior page's next - // cursor ("" on the first request). - "cursor": scalars.NewCursor(resourceTypesPageSize, after), - }, - } - if err := client.MakeRequest(ctx, req, &graphql.Response{Data: &resp}); err != nil { - return nil, fmt.Errorf("list resource types: %w", err) - } - all = append(all, resp.ResourceTypes.Items...) - - // Stop at the last page. The `next == after` guard is a belt-and-braces - // defense against a server that echoes the same cursor, which would - // otherwise loop forever. - next := resp.ResourceTypes.Cursor.Next - if next == "" || next == after { - break - } - after = next - } - return all, nil -} - -// PublishResourceType registers a resource-type schema. -func PublishResourceType(ctx context.Context, mdClient *massdriver.Client, input PublishResourceTypeInput) (*ResourceType, error) { - cfg := mdClient.Config() - - // The schema field is a GraphQL `Map!` scalar — wire format is a - // JSON-encoded string. scalars.MarshalJSON is the canonical encoder the - // genqlient codegen uses; reuse it so the wire shape stays in lockstep. - schemaRaw, err := scalars.MarshalJSON(input.Schema) - if err != nil { - return nil, fmt.Errorf("marshal resource-type schema: %w", err) - } - - var resp struct { - PublishResourceType resourceTypeMutationResult `json:"publishResourceType"` - } - req := &graphql.Request{ - OpName: "publishResourceType", - Query: publishResourceTypeMutation, - Variables: map[string]any{ - "organizationId": cfg.OrganizationID, - "input": map[string]any{"schema": json.RawMessage(schemaRaw)}, - }, - } - if err := gqlClient(mdClient).MakeRequest(ctx, req, &graphql.Response{Data: &resp}); err != nil { - return nil, fmt.Errorf("publish resource type: %w", err) - } - if !resp.PublishResourceType.Successful { - return nil, mutationError("publish resource type", resp.PublishResourceType.Messages) - } - return resp.PublishResourceType.Result, nil -} - -// DeleteResourceType removes a resource type by name. -func DeleteResourceType(ctx context.Context, mdClient *massdriver.Client, name string) (*ResourceType, error) { - cfg := mdClient.Config() - var resp struct { - DeleteResourceType resourceTypeMutationResult `json:"deleteResourceType"` - } - req := &graphql.Request{ - OpName: "deleteResourceType", - Query: deleteResourceTypeMutation, - Variables: map[string]any{ - "organizationId": cfg.OrganizationID, - "id": name, - }, - } - if err := gqlClient(mdClient).MakeRequest(ctx, req, &graphql.Response{Data: &resp}); err != nil { - return nil, fmt.Errorf("delete resource type %s: %w", name, err) - } - if !resp.DeleteResourceType.Successful { - return nil, mutationError("delete resource type "+name, resp.DeleteResourceType.Messages) - } - return resp.DeleteResourceType.Result, nil -} diff --git a/internal/bundle/publish.go b/internal/bundle/publish.go index 297b59c4..257497b3 100644 --- a/internal/bundle/publish.go +++ b/internal/bundle/publish.go @@ -1,107 +1,27 @@ package bundle import ( - "bytes" - "context" "fmt" "os" "path/filepath" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" ignore "github.com/sabhiram/go-gitignore" - oras "oras.land/oras-go/v2" - "oras.land/oras-go/v2/content" ) -// Publisher handles packaging and publishing bundles to an OCI registry. -type Publisher struct { - Store oras.Target - Repo oras.Target -} - -// PublishBundle copies the packaged bundle manifest from the local store to the remote repository. -func (p *Publisher) PublishBundle(ctx context.Context, tag string) error { - _, copyErr := oras.Copy(ctx, p.Store, tag, p.Repo, tag, oras.DefaultCopyOptions) - return copyErr -} +// ArtifactType is the OCI artifact-type media type for bundles. +const ArtifactType = "application/vnd.massdriver.bundle.v1+json" -// PackageBundle walks bundleDir, pushes all files to the OCI store, and creates a manifest tagged with tag. -func (p *Publisher) PackageBundle(ctx context.Context, bundleDir string, tag string) (ocispec.Descriptor, error) { +// PackageKeep returns the keep predicate used when packaging a bundle. It honors +// a bundle's optional .mdignore file, falling back to a default allowlist that +// only lets the expected bundle files through. +func PackageKeep(bundleDir string) (func(relPath string) bool, error) { ignoreMatcher, ignoreErr := getIgnores(filepath.Join(bundleDir, ".mdignore")) if ignoreErr != nil { - return ocispec.Descriptor{}, ignoreErr - } - - var layers []ocispec.Descriptor - var pushedDigests = make(map[string]string) - if walkErr := filepath.Walk(bundleDir, func(file string, fi os.FileInfo, err error) error { - if err != nil { - return err - } - if fi.IsDir() { - return nil - } - - // Calculate relative path from bundle directory - bundleRelativePath, err := filepath.Rel(bundleDir, file) - if err != nil { - return err - } - bundleRelativePath = filepath.ToSlash(bundleRelativePath) - - if ignoreMatcher != nil && ignoreMatcher.MatchesPath(bundleRelativePath) { - return nil - } - - descriptor, addErr := addFileToStore(ctx, p.Store, file, bundleRelativePath, pushedDigests) - if addErr != nil { - return addErr - } - layers = append(layers, *descriptor) - - return nil - }); walkErr != nil { - return ocispec.Descriptor{}, walkErr - } - - // 3. Pack the files and tag the packed manifest - artifactType := "application/vnd.massdriver.bundle.v1+json" - opts := oras.PackManifestOptions{ - Layers: layers, - } - manifestDescriptor, packErr := oras.PackManifest(ctx, p.Store, oras.PackManifestVersion1_1, artifactType, opts) - if packErr != nil { - return ocispec.Descriptor{}, packErr + return nil, ignoreErr } - - if tagErr := p.Store.Tag(ctx, manifestDescriptor, tag); tagErr != nil { - return ocispec.Descriptor{}, tagErr - } - - return manifestDescriptor, nil -} - -func addFileToStore(ctx context.Context, store content.Pusher, filePath string, relativePath string, pushedDigests map[string]string) (*ocispec.Descriptor, error) { - data, readErr := os.ReadFile(filePath) - if readErr != nil { - return nil, fmt.Errorf("reading %s: %w", filePath, readErr) - } - - mimeType := getMimeTypeFromExtension(filepath.Ext(filePath)) - descriptor := content.NewDescriptorFromBytes(mimeType, data) - descriptor.Annotations = map[string]string{ - ocispec.AnnotationTitle: relativePath, - } - - digest := descriptor.Digest.String() - if _, exists := pushedDigests[digest]; !exists { - pushErr := store.Push(ctx, descriptor, bytes.NewReader(data)) - if pushErr != nil { - return nil, fmt.Errorf("pushing %s: %w", filePath, pushErr) - } - pushedDigests[digest] = relativePath - } - return &descriptor, nil + return func(relPath string) bool { + return ignoreMatcher == nil || !ignoreMatcher.MatchesPath(relPath) + }, nil } // Loads patterns from .mdignore file and returns a matcher @@ -154,71 +74,3 @@ func getIgnores(ignorePath string) (*ignore.GitIgnore, error) { } return gi, nil } - -func getMimeTypeFromExtension(ext string) string { - if mimeType, exists := mimeTypesFromExt[ext]; exists { - return mimeType - } - return "" -} - -var mimeTypesFromExt = map[string]string{ - // Text formats - ".txt": "text/plain", - ".md": "text/markdown", - ".mdx": "text/markdown", - ".csv": "text/csv", - ".log": "text/plain", - // Configuration / serialization - ".json": "application/json", - ".yaml": "application/yaml", - ".yml": "application/yaml", - ".toml": "application/toml", - ".ini": "text/plain", // technically ambiguous - // HTML, XML - ".html": "text/html", - ".xml": "application/xml", - // Source code - ".go": "text/x-go", - ".py": "text/x-python", - ".js": "application/javascript", - ".ts": "application/typescript", - ".java": "text/x-java-source", - ".rb": "text/x-ruby", - ".sh": "application/x-sh", - ".bash": "application/x-sh", - ".c": "text/x-c", - ".cpp": "text/x-c++", - ".cs": "text/x-csharp", - ".php": "application/x-httpd-php", - // Infrastructure as code / DevOps - ".tf": "application/hcl", - ".tfvars": "application/hcl", - ".hcl": "application/hcl", - ".rego": "text/plain", // Open Policy Agent - ".dockerfile": "text/x-dockerfile", - // Shell scripts / dotfiles - ".env": "text/plain", - ".gitignore": "text/plain", - ".gitattributes": "text/plain", - ".bashrc": "text/x-shellscript", - // Archives - ".zip": "application/x-zip-compressed", - ".tar": "application/x-tar", - ".gz": "application/x-gzip", - ".tgz": "application/x-gzip", - ".tar.gz": "application/x-gzip", - // Binary - ".exe": "application/vnd.microsoft.portable-executable", - ".dll": "application/vnd.microsoft.portable-executable", - ".wasm": "application/wasm", - // Images (commonly used in docs/pipelines) - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".svg": "image/svg+xml", - // Certificates / keys - ".pem": "application/x-pem-file", - ".crt": "application/x-x509-ca-cert", - ".key": "application/x-pem-file", -} diff --git a/internal/bundle/publish_test.go b/internal/bundle/publish_test.go index 9ae943e7..3b25e6e9 100644 --- a/internal/bundle/publish_test.go +++ b/internal/bundle/publish_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/massdriver-cloud/mass/internal/bundle" + "github.com/massdriver-cloud/mass/internal/oci" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content/memory" ) @@ -38,14 +39,19 @@ func TestPackageBundle(t *testing.T) { t.Run(tc.name, func(t *testing.T) { memStore := memory.New() - p := bundle.Publisher{ + p := oci.Publisher{ Store: memStore, } + keep, keepErr := bundle.PackageKeep(tc.bundleDir) + if keepErr != nil { + t.Fatalf("PackageKeep failed: %v", keepErr) + } + tag := "test-tag" - desc, err := p.PackageBundle(t.Context(), tc.bundleDir, tag) + desc, err := p.Package(t.Context(), tc.bundleDir, tag, bundle.ArtifactType, keep) if err != nil { - t.Fatalf("PackageBundle failed: %v", err) + t.Fatalf("Package failed: %v", err) } // Fetch and parse the manifest diff --git a/internal/bundle/pull.go b/internal/bundle/pull.go deleted file mode 100644 index 752830f1..00000000 --- a/internal/bundle/pull.go +++ /dev/null @@ -1,19 +0,0 @@ -package bundle - -import ( - "context" - - v1 "github.com/opencontainers/image-spec/specs-go/v1" - oras "oras.land/oras-go/v2" -) - -// Puller handles pulling bundles from an OCI registry into a local target. -type Puller struct { - Target oras.Target - Repo oras.Target -} - -// PullBundle copies the bundle at the given version from the remote repository to the local target. -func (p *Puller) PullBundle(ctx context.Context, version string) (v1.Descriptor, error) { - return oras.Copy(ctx, p.Repo, version, p.Target, version, oras.DefaultCopyOptions) -} diff --git a/internal/commands/bundle/publish.go b/internal/commands/bundle/publish.go index c3e0874e..10bf5b3d 100644 --- a/internal/commands/bundle/publish.go +++ b/internal/commands/bundle/publish.go @@ -7,6 +7,7 @@ import ( "time" "github.com/massdriver-cloud/mass/internal/bundle" + "github.com/massdriver-cloud/mass/internal/oci" "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" @@ -30,14 +31,19 @@ func RunPublish(ctx context.Context, b *bundle.Bundle, mdClient *massdriver.Clie return fmt.Errorf("getting repository: %w", repoErr) } store := memory.New() - publisher := &bundle.Publisher{ + publisher := &oci.Publisher{ Store: store, Repo: repo, } fmt.Printf("Packaging bundle %s...\n", printBundleName) - manifestDescriptor, packageErr := publisher.PackageBundle(ctx, buildFromDir, version) + keep, keepErr := bundle.PackageKeep(buildFromDir) + if keepErr != nil { + return fmt.Errorf("packaging bundle: %w", keepErr) + } + + manifestDescriptor, packageErr := publisher.Package(ctx, buildFromDir, version, bundle.ArtifactType, keep) if packageErr != nil { return fmt.Errorf("packaging bundle: %w", packageErr) } @@ -45,7 +51,7 @@ func RunPublish(ctx context.Context, b *bundle.Bundle, mdClient *massdriver.Clie fmt.Printf("Package %s created with digest: %s\n", printBundleName, manifestDescriptor.Digest) fmt.Printf("Pushing %s to package manager...\n", printBundleName) - publishErr := publisher.PublishBundle(ctx, version) + publishErr := publisher.Publish(ctx, version) if publishErr != nil { return fmt.Errorf("publishing bundle: %w", publishErr) } diff --git a/internal/commands/bundle/pull.go b/internal/commands/bundle/pull.go index 045a110f..85c29025 100644 --- a/internal/commands/bundle/pull.go +++ b/internal/commands/bundle/pull.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/massdriver-cloud/mass/internal/bundle" + "github.com/massdriver-cloud/mass/internal/oci" "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "oras.land/oras-go/v2/content/file" @@ -36,12 +36,12 @@ func RunPull(ctx context.Context, mdClient *massdriver.Client, bundleName string } defer store.Close() - puller := &bundle.Puller{ + puller := &oci.Puller{ Target: store, Repo: repo, } - descriptor, pullErr := puller.PullBundle(ctx, tag) + descriptor, pullErr := puller.Pull(ctx, tag) if pullErr != nil { return fmt.Errorf("failed to pull bundle: %w", pullErr) } diff --git a/internal/commands/instance/export.go b/internal/commands/instance/export.go index efeae9c7..9b94162a 100644 --- a/internal/commands/instance/export.go +++ b/internal/commands/instance/export.go @@ -10,7 +10,7 @@ import ( "os" "path/filepath" - "github.com/massdriver-cloud/mass/internal/bundle" + "github.com/massdriver-cloud/mass/internal/oci" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" "oras.land/oras-go/v2/content/file" @@ -88,12 +88,12 @@ func (dbf *DefaultBundleFetcher) FetchBundle(ctx context.Context, bundleName, ve } defer store.Close() - puller := &bundle.Puller{ + puller := &oci.Puller{ Target: store, Repo: repo, } - _, pullErr := puller.PullBundle(ctx, version) + _, pullErr := puller.Pull(ctx, version) return pullErr } diff --git a/internal/commands/resourcetype/convert.go b/internal/commands/resourcetype/convert.go new file mode 100644 index 00000000..f53e1b3c --- /dev/null +++ b/internal/commands/resourcetype/convert.go @@ -0,0 +1,225 @@ +package resourcetype + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/massdriver-cloud/mass/internal/resourcetype" + "gopkg.in/yaml.v3" +) + +// placeholderVersion is written into the converted massdriver.yaml since a raw +// JSON schema carries no version. The author must set a real version before +// publishing. +const placeholderVersion = "0.0.0" + +// ConvertResult describes the files a RunConvert call produced. +type ConvertResult struct { + MassdriverYAML string // path to the written massdriver.yaml + ExtraFiles []string // paths to extracted instruction/export files +} + +// RunConvert reads a raw JSON (or YAML) resource type schema at schemaPath and +// writes an equivalent massdriver.yaml. Inlined instruction/export content is +// extracted back out to referenced files. outputPath is the massdriver.yaml to +// write; when empty it defaults to a massdriver.yaml alongside schemaPath. +// Existing files are not overwritten unless force is set. +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) + } + + // Refuse to clobber anything unless forced. + targets := []string{outputPath} + for rel := range extraFiles { + targets = append(targets, filepath.Join(outputDir, rel)) + } + if !force { + for _, t := range targets { + if _, statErr := os.Stat(t); statErr == nil { + return nil, fmt.Errorf("%s already exists; use --force to overwrite", t) + } + } + } + + if mkErr := os.MkdirAll(outputDir, 0750); mkErr != nil { + return nil, fmt.Errorf("failed to create output directory: %w", mkErr) + } + + result := &ConvertResult{MassdriverYAML: outputPath} + for rel, content := range extraFiles { + dst := filepath.Join(outputDir, rel) + if mkErr := os.MkdirAll(filepath.Dir(dst), 0750); mkErr != nil { + return nil, fmt.Errorf("failed to create directory for %s: %w", rel, mkErr) + } + if writeErr := os.WriteFile(dst, content, 0600); writeErr != nil { + return nil, fmt.Errorf("failed to write %s: %w", rel, writeErr) + } + result.ExtraFiles = append(result.ExtraFiles, dst) + } + + if writeErr := os.WriteFile(outputPath, out, 0600); writeErr != nil { + return nil, fmt.Errorf("failed to write %s: %w", outputPath, writeErr) + } + + return result, nil +} + +func readRawSchema(path string) (map[string]any, error) { + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil, fmt.Errorf("failed to read schema: %w", readErr) + } + + var raw map[string]any + switch strings.ToLower(filepath.Ext(path)) { + case ".json": + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse JSON schema: %w", err) + } + case ".yaml", ".yml": + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse YAML schema: %w", err) + } + default: + return nil, fmt.Errorf("unsupported schema file extension: %s (expected .json, .yaml, or .yml)", filepath.Ext(path)) + } + return raw, nil +} + +// reverseBuild is the inverse of resourcetype.Build: it lifts the `$md` block +// back into the massdriver.yaml fields, extracts inlined instruction/export +// content into files keyed by their relative path, and moves the remaining keys +// under `schema`. +func reverseBuild(raw map[string]any) (*resourcetype.MassdriverYAML, map[string][]byte) { + config := &resourcetype.MassdriverYAML{Version: placeholderVersion} + extraFiles := map[string][]byte{} + + if md, ok := raw["$md"].(map[string]any); ok { + 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 +} + +// uniqueRel builds "/.", appending an incrementing numeric +// suffix until the path is unused, so two items that reduce to the same name +// don't clobber each other's extracted file. +func uniqueRel(extraFiles map[string][]byte, dir, name, ext string) string { + base := fmt.Sprintf("%s/%s", dir, name) + rel := base + "." + ext + for n := 2; ; n++ { + if _, taken := extraFiles[rel]; !taken { + return rel + } + rel = fmt.Sprintf("%s-%d.%s", base, n, ext) + } +} + +func asString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +var nonFilenameChars = regexp.MustCompile(`[^a-z0-9]+`) + +// sanitize turns a human label into a filesystem-friendly name, falling back to +// an index-based name when the label has no usable characters. +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..55a4c7a3 --- /dev/null +++ b/internal/commands/resourcetype/convert_test.go @@ -0,0 +1,105 @@ +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() + // Labels crafted to trip the old (buggy) unique-path logic: the third + // instruction's fallback name collided with the first's. + raw := `{ + "$md": { + "name": "dup", + "ui": { "instructions": [ + { "label": "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) + } + } +} + +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..ab3a0aa4 --- /dev/null +++ b/internal/commands/resourcetype/delete.go @@ -0,0 +1,26 @@ +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. Because published versions +// are immutable, deletion is refused locally when the repository already has +// tags. UX (confirmation prompt, success message) is the caller's +// responsibility — see cmd.runTypeDelete. +func 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..a517d777 --- /dev/null +++ b/internal/commands/resourcetype/publish.go @@ -0,0 +1,247 @@ +// 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/jsonschema" + "github.com/massdriver-cloud/mass/internal/oci" + "github.com/massdriver-cloud/mass/internal/resourcetype" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "oras.land/oras-go/v2/content/memory" +) + +// allowedFiles is the exact set of top-level files that may be packaged into a +// resource type artifact. readme/changelog are listed in both their +// conventional uppercase and lowercase forms; everything else at the top level +// is silently skipped. +var allowedFiles = []string{ + "massdriver.yaml", + "README.md", + "readme.md", + "CHANGELOG.md", + "changelog.md", + "icon.svg", + "icon.png", + "icon.jpg", + "icon.jpeg", +} + +// referencedPaths returns the raw instruction and export template file +// references declared in a massdriver.yaml, in declaration order. +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 +} + +// packageKeep builds the keep predicate used when packaging a resource type. +// It admits the allowlisted top-level files plus the exact instruction and +// export template files the massdriver.yaml references (wherever they live in +// the directory tree), and silently skips everything else. +func packageKeep(config *resourcetype.MassdriverYAML) func(relPath string) bool { + referenced := map[string]bool{} + for _, p := range referencedPaths(config) { + if norm := normalizeRel(p); norm != "" { + referenced[norm] = true + } + } + + return func(relPath string) bool { + return referenced[relPath] || slices.Contains(allowedFiles, relPath) + } +} + +// validateReferencedFiles ensures every instruction/export file the +// massdriver.yaml references resolves to a real file inside srcDir. References +// that are absolute, escape the directory, or don't exist would be dropped by +// the packager and produce a silently incomplete artifact, so they're rejected +// up front. +func validateReferencedFiles(config *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 converts a massdriver.yaml file reference (relative to the +// massdriver.yaml, e.g. "./instructions/cli.md") into the slash-separated, +// cleaned form the packager's keep predicate receives. Empty and non-local +// (absolute or parent-escaping) references return "" since they can't match a +// file walked under the resource type directory. +func normalizeRel(p string) string { + if p == "" { + return "" + } + cleaned := filepath.ToSlash(filepath.Clean(p)) + if cleaned == "." || filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "../") { + return "" + } + return cleaned +} + +// RunPublish validates a resource type located at path and pushes it to its OCI +// repository. path may be a directory containing a massdriver.yaml, or the +// massdriver.yaml itself. It returns the resource type name and the published +// version. +func RunPublish(ctx context.Context, mdClient *massdriver.Client, path string) (string, string, error) { + mdYamlPath, srcDir, resolveErr := resolvePublishPath(path) + if resolveErr != nil { + return "", "", resolveErr + } + + config, configErr := 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 == "" { + return "", "", fmt.Errorf("version is required in %s", mdYamlPath) + } + + // Referenced instruction/export files must live inside the packaged + // directory, otherwise the artifact would ship incomplete. + if refErr := validateReferencedFiles(config, srcDir); refErr != nil { + return "", "", refErr + } + + // Fail fast on a duplicate version before the network-heavy schema + // dereference and validation. + if versionErr := checkDuplicateVersion(ctx, mdClient, config.Name, config.Version); versionErr != nil { + 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 +} + +// resolvePublishPath resolves the publish target into the massdriver.yaml path +// and its containing directory, rejecting raw JSON schema files with a pointer +// to the convert command. +func resolvePublishPath(path string) (mdYamlPath string, srcDir string, err error) { + info, statErr := os.Stat(path) + if statErr != nil { + return "", "", fmt.Errorf("failed to read resource type path: %w", statErr) + } + + if info.IsDir() { + md := filepath.Join(path, "massdriver.yaml") + if _, mdErr := os.Stat(md); mdErr != nil { + return "", "", fmt.Errorf("no massdriver.yaml found in %s", path) + } + return md, path, nil + } + + if filepath.Base(path) == "massdriver.yaml" { + return path, filepath.Dir(path), nil + } + + switch strings.ToLower(filepath.Ext(path)) { + case ".json", ".yaml", ".yml": + return "", "", fmt.Errorf("publishing a raw JSON schema is no longer supported; run `mass resource-type convert %s` to migrate it to a massdriver.yaml", path) + default: + return "", "", fmt.Errorf("unsupported resource type path: %s (expected a directory or massdriver.yaml)", path) + } +} + +// validateSchema builds and dereferences the resource type, then validates it +// against the resource type schema and the JSON Schema meta-schema. +func validateSchema(ctx context.Context, mdClient *massdriver.Client, mdYamlPath string) error { + rt, readErr := resourcetype.Read(ctx, mdClient, mdYamlPath) + if readErr != nil { + return fmt.Errorf("failed to read resource type: %w", readErr) + } + + 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 fails locally if version has already been published, +// matching the immutability the API enforces. +func checkDuplicateVersion(ctx context.Context, mdClient *massdriver.Client, name, version string) error { + repo, err := mdClient.OciRepos.Get(ctx, name) + if err != nil { + return fmt.Errorf("fetching OCI repo: %w", err) + } + for _, t := range repo.Tags { + if t.Tag == version { + return fmt.Errorf("version %s already exists for resource type %s", version, name) + } + } + return nil +} + +func validateResourceType(rt map[string]any, schemaURL string) error { + 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..2016fb9d --- /dev/null +++ b/internal/commands/resourcetype/publish_test.go @@ -0,0 +1,55 @@ +package resourcetype_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + cmdresourcetype "github.com/massdriver-cloud/mass/internal/commands/resourcetype" +) + +// TestRunPublishValidation covers the local validation RunPublish performs +// before it touches the OCI registry: rejecting raw schema files (pointing at +// convert) and requiring name/version in the massdriver.yaml. These paths +// short-circuit before the massdriver client is used, so a nil client is fine. +func TestRunPublishValidation(t *testing.T) { + dir := t.TempDir() + + rawJSON := filepath.Join(dir, "schema.json") + if err := os.WriteFile(rawJSON, []byte("{}"), 0600); err != nil { + t.Fatal(err) + } + noVersionDir := t.TempDir() + if err := os.WriteFile(filepath.Join(noVersionDir, "massdriver.yaml"), []byte("name: foo\n"), 0600); err != nil { + t.Fatal(err) + } + noNameDir := t.TempDir() + if err := os.WriteFile(filepath.Join(noNameDir, "massdriver.yaml"), []byte("version: 1.0.0\n"), 0600); err != nil { + t.Fatal(err) + } + emptyDir := t.TempDir() + + tests := []struct { + name string + path string + contains string + }{ + {name: "raw JSON schema rejected", path: rawJSON, contains: "convert"}, + {name: "directory without massdriver.yaml", path: emptyDir, contains: "no massdriver.yaml"}, + {name: "missing version", path: noVersionDir, contains: "version is required"}, + {name: "missing name", path: noNameDir, contains: "name is required"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, _, err := cmdresourcetype.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) + } + }) + } +} diff --git a/internal/commands/resourcetype/pull.go b/internal/commands/resourcetype/pull.go new file mode 100644 index 00000000..f819c29c --- /dev/null +++ b/internal/commands/resourcetype/pull.go @@ -0,0 +1,78 @@ +package resourcetype + +import ( + "context" + "fmt" + + "github.com/massdriver-cloud/mass/internal/oci" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "oras.land/oras-go/v2/content/file" +) + +// RunPull downloads a resource type from its OCI repository into directory, +// resolving version to a concrete tag. It returns the resolved tag and the +// pulled manifest digest. +func 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: %w", pullErr) + } + + return tag, descriptor.Digest.String(), nil +} + +// resolveTag maps a user-supplied version (a concrete tag, a release channel +// name, or "latest") to a concrete OCI tag. +func resolveTag(ctx context.Context, mdClient *massdriver.Client, name, version string) (string, error) { + repo, getErr := mdClient.OciRepos.Get(ctx, name) + if getErr != nil { + return "", fmt.Errorf("failed to get OCI repo: %w", getErr) + } + + if version == "" || version == "latest" { + // Prefer the "latest" release channel; otherwise fall back to the newest + // tag (the Get query returns tags sorted by version, descending). + if repo.LatestTag != "" { + return repo.LatestTag, nil + } + if len(repo.Tags) > 0 { + return repo.Tags[0].Tag, nil + } + return "", fmt.Errorf("no published versions found for resource type '%s'", name) + } + + for _, t := range repo.Tags { + if t.Tag == version { + return version, nil + } + } + + for _, channel := range repo.ReleaseChannels { + if version == channel.Name { + return channel.Tag, nil + } + } + + return "", fmt.Errorf("version or release channel '%s' not found for resource type '%s'", version, name) +} 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..4e4f989a --- /dev/null +++ b/internal/oci/oci.go @@ -0,0 +1,188 @@ +// Package oci contains the raw OCI packaging, publishing, and pulling logic +// shared by bundles and resource types. Callers supply the artifact-type media +// type and a per-file keep predicate; everything else (walking the directory, +// pushing layers, packing the manifest, copying to/from the remote repo) is +// identical across artifact kinds and lives here. +package oci + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + oras "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" +) + +// Publisher packages a local directory into an OCI store and pushes it to a +// remote repository. +type Publisher struct { + Store oras.Target + Repo oras.Target +} + +// Publish copies the packaged manifest from the local store to the remote +// repository under tag. +func (p *Publisher) Publish(ctx context.Context, tag string) error { + _, copyErr := oras.Copy(ctx, p.Store, tag, p.Repo, tag, oras.DefaultCopyOptions) + return copyErr +} + +// Package walks srcDir and pushes every file for which keep returns true into +// the store, then packs and tags a manifest of the given artifactType. A nil +// keep predicate includes every file. Paths passed to keep are slash-separated +// and relative to srcDir. +func (p *Publisher) Package(ctx context.Context, srcDir, tag, artifactType string, keep func(relPath string) bool) (ocispec.Descriptor, error) { + var layers []ocispec.Descriptor + pushedDigests := make(map[string]string) + + if walkErr := filepath.Walk(srcDir, func(file string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + if fi.IsDir() { + return nil + } + + relativePath, relErr := filepath.Rel(srcDir, file) + if relErr != nil { + return relErr + } + relativePath = filepath.ToSlash(relativePath) + + if keep != nil && !keep(relativePath) { + return nil + } + + descriptor, addErr := addFileToStore(ctx, p.Store, file, relativePath, pushedDigests) + if addErr != nil { + return addErr + } + layers = append(layers, *descriptor) + + return nil + }); walkErr != nil { + return ocispec.Descriptor{}, walkErr + } + + opts := oras.PackManifestOptions{ + Layers: layers, + } + manifestDescriptor, packErr := oras.PackManifest(ctx, p.Store, oras.PackManifestVersion1_1, artifactType, opts) + if packErr != nil { + return ocispec.Descriptor{}, packErr + } + + if tagErr := p.Store.Tag(ctx, manifestDescriptor, tag); tagErr != nil { + return ocispec.Descriptor{}, tagErr + } + + return manifestDescriptor, nil +} + +// Puller copies an artifact from a remote repository into a local target. +type Puller struct { + Target oras.Target + Repo oras.Target +} + +// Pull copies the artifact at tag from the remote repository into the target. +func (p *Puller) Pull(ctx context.Context, tag string) (ocispec.Descriptor, error) { + return oras.Copy(ctx, p.Repo, tag, p.Target, tag, oras.DefaultCopyOptions) +} + +func addFileToStore(ctx context.Context, store content.Pusher, filePath, relativePath string, pushedDigests map[string]string) (*ocispec.Descriptor, error) { + data, readErr := os.ReadFile(filePath) + if readErr != nil { + return nil, fmt.Errorf("reading %s: %w", filePath, readErr) + } + + mimeType := MimeTypeFromExtension(filepath.Ext(filePath)) + descriptor := content.NewDescriptorFromBytes(mimeType, data) + descriptor.Annotations = map[string]string{ + ocispec.AnnotationTitle: relativePath, + } + + digest := descriptor.Digest.String() + if _, exists := pushedDigests[digest]; !exists { + pushErr := store.Push(ctx, descriptor, bytes.NewReader(data)) + if pushErr != nil { + return nil, fmt.Errorf("pushing %s: %w", filePath, pushErr) + } + pushedDigests[digest] = relativePath + } + return &descriptor, nil +} + +// MimeTypeFromExtension returns the media type for a file extension (including +// the leading dot), or the empty string when unknown. +func MimeTypeFromExtension(ext string) string { + if mimeType, exists := mimeTypesFromExt[ext]; exists { + return mimeType + } + return "" +} + +var mimeTypesFromExt = map[string]string{ + // Text formats + ".txt": "text/plain", + ".md": "text/markdown", + ".mdx": "text/markdown", + ".csv": "text/csv", + ".log": "text/plain", + // Configuration / serialization + ".json": "application/json", + ".yaml": "application/yaml", + ".yml": "application/yaml", + ".toml": "application/toml", + ".ini": "text/plain", // technically ambiguous + // HTML, XML + ".html": "text/html", + ".xml": "application/xml", + // Source code + ".go": "text/x-go", + ".py": "text/x-python", + ".js": "application/javascript", + ".ts": "application/typescript", + ".java": "text/x-java-source", + ".rb": "text/x-ruby", + ".sh": "application/x-sh", + ".bash": "application/x-sh", + ".c": "text/x-c", + ".cpp": "text/x-c++", + ".cs": "text/x-csharp", + ".php": "application/x-httpd-php", + // Infrastructure as code / DevOps + ".tf": "application/hcl", + ".tfvars": "application/hcl", + ".hcl": "application/hcl", + ".rego": "text/plain", // Open Policy Agent + ".dockerfile": "text/x-dockerfile", + // Shell scripts / dotfiles + ".env": "text/plain", + ".gitignore": "text/plain", + ".gitattributes": "text/plain", + ".bashrc": "text/x-shellscript", + // Archives + ".zip": "application/x-zip-compressed", + ".tar": "application/x-tar", + ".gz": "application/x-gzip", + ".tgz": "application/x-gzip", + ".tar.gz": "application/x-gzip", + // Binary + ".exe": "application/vnd.microsoft.portable-executable", + ".dll": "application/vnd.microsoft.portable-executable", + ".wasm": "application/wasm", + // Images (commonly used in docs/pipelines) + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", + // Certificates / keys + ".pem": "application/x-pem-file", + ".crt": "application/x-x509-ca-cert", + ".key": "application/x-pem-file", +} diff --git a/internal/bundle/pull_test.go b/internal/oci/pull_test.go similarity index 94% rename from internal/bundle/pull_test.go rename to internal/oci/pull_test.go index f9e0908c..337515b6 100644 --- a/internal/bundle/pull_test.go +++ b/internal/oci/pull_test.go @@ -1,11 +1,11 @@ -package bundle_test +package oci_test import ( "bytes" "encoding/json" "testing" - "github.com/massdriver-cloud/mass/internal/bundle" + "github.com/massdriver-cloud/mass/internal/oci" ocispec "github.com/opencontainers/image-spec/specs-go/v1" oras "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" @@ -77,11 +77,11 @@ func TestPull(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - puller := &bundle.Puller{ + puller := &oci.Puller{ Target: tc.target, Repo: tc.repo, } - desc, pullErr := puller.PullBundle(t.Context(), tc.tag) + desc, pullErr := puller.Pull(t.Context(), tc.tag) if (pullErr != nil) != tc.wantErr { t.Fatalf("unexpected error = %v, wantErr %v", pullErr, tc.wantErr) } diff --git a/internal/resourcetype/build.go b/internal/resourcetype/build.go index dc8a1643..0ab23e0f 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,9 @@ type ExportConfig struct { TemplateLang string `yaml:"templateLang"` } -// Build reads a massdriver.yaml file and builds it into the resource type -// format expected by the Massdriver API. -func Build(path string) (map[string]any, error) { +// ReadConfig reads and parses a massdriver.yaml resource type file into its +// structured form without dereferencing or building the schema. +func ReadConfig(path string) (*MassdriverYAML, error) { content, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read massdriver.yaml: %w", err) @@ -54,6 +58,17 @@ func Build(path string) (map[string]any, error) { return nil, fmt.Errorf("failed to parse massdriver.yaml: %w", err) } + return &config, nil +} + +// Build reads a massdriver.yaml file and builds it into the resource type +// format expected by the Massdriver API. +func Build(path string) (map[string]any, error) { + config, err := ReadConfig(path) + if err != nil { + return nil, err + } + baseDir := filepath.Dir(path) // Build the $md block diff --git a/internal/resourcetype/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/get.go b/internal/resourcetype/get.go index 61cb618d..b1dce7bd 100644 --- a/internal/resourcetype/get.go +++ b/internal/resourcetype/get.go @@ -1,26 +1,25 @@ -// Package resourcetype provides CLI helpers around resource-type operations. -// -// The underlying GraphQL surface lives in [github.com/massdriver-cloud/mass/internal/api], -// a temporary holding pen for ops not yet exposed by the Massdriver SDK. When -// the SDK adds native resource-type support this package collapses to thin -// wrappers over the SDK and `internal/api` is deleted. +// Package resourcetype provides CLI helpers around resource-type operations, +// thin wrappers over the Massdriver SDK's resource-type and OCI-repo services. package resourcetype import ( "context" "encoding/json" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/ocirepos" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/resourcetypes" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" ) -// ResourceType is an alias of [api.ResourceType] so consumers stay decoupled -// from the holding-pen package import. -type ResourceType = api.ResourceType +// ResourceType is an alias of the SDK's resource-type record so consumers stay +// decoupled from the SDK import path. +type ResourceType = resourcetypes.ResourceType -// Get retrieves a resource type by name from the Massdriver API. +// Get retrieves a resource type by name (optionally `name@version`) from +// Massdriver, including its resolved JSON schema. func Get(ctx context.Context, mdClient *massdriver.Client, resourceTypeName string) (*ResourceType, error) { - return api.GetResourceType(ctx, mdClient, resourceTypeName) + return mdClient.ResourceTypes.Get(ctx, resourceTypeName) } // GetAsMap retrieves a resource type and returns it as a generic map. @@ -40,7 +39,28 @@ func GetAsMap(ctx context.Context, mdClient *massdriver.Client, resourceTypeName return result, unmarshalErr } -// List returns every resource type in the configured organization. +// List returns every resource type in the configured organization, sourced from +// the OCI repository catalog filtered to resource-type artifacts. The returned +// records carry only catalog metadata (ID, name, icon, timestamps); use [Get] +// to fetch a single resource type's schema. func List(ctx context.Context, mdClient *massdriver.Client) ([]ResourceType, error) { - return api.ListResourceTypes(ctx, mdClient) + seq := mdClient.OciRepos.Iter(ctx, ocirepos.ListInput{ + ArtifactType: ocirepos.ArtifactTypeResourceType, + }) + repos, collectErr := types.Collect(seq) + if collectErr != nil { + return nil, collectErr + } + + resourceTypes := make([]ResourceType, len(repos)) + for i, repo := range repos { + resourceTypes[i] = ResourceType{ + ID: repo.ID, + Name: repo.Name, + Icon: repo.Icon, + CreatedAt: repo.CreatedAt, + UpdatedAt: repo.UpdatedAt, + } + } + return resourceTypes, nil } diff --git a/internal/resourcetype/get_test.go b/internal/resourcetype/get_test.go index 104f7cc4..6a22239d 100644 --- a/internal/resourcetype/get_test.go +++ b/internal/resourcetype/get_test.go @@ -1,99 +1,17 @@ package resourcetype_test import ( - "reflect" "testing" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/resourcetype" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql/gqltest" ) -func TestGet(t *testing.T) { - type test struct { - name string - resourceType map[string]any - want resourcetype.ResourceType - } - tests := []test{ - { - name: "simple", - resourceType: map[string]any{ - "id": "123-456", - "name": "massdriver/test-schema", - "schema": map[string]any{ - "$id": "https://example.com/schemas/test-schema.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "A test schema for demonstration purposes.", - }, - }, - want: resourcetype.ResourceType{ - ID: "123-456", - Name: "massdriver/test-schema", - Schema: map[string]any{ - "$id": "https://example.com/schemas/test-schema.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "A test schema for demonstration purposes.", - }, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - mock := gqltest.NewClient( - gqltest.RespondWithData(map[string]any{ - "resourceType": tc.resourceType, - }), - ) - t.Cleanup(api.SetTransportForTest(mock)) - mdClient, err := massdriver.NewClient( - massdriver.WithGQLClient(mock), - massdriver.WithOrganizationID("test-org"), - ) - if err != nil { - t.Fatal(err) - } - - got, err := resourcetype.Get(t.Context(), mdClient, "massdriver/test-schema") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !reflect.DeepEqual(*got, tc.want) { - t.Errorf("got %v, want %v", *got, tc.want) - } - }) - } -} - -// TestListWalksPages verifies that List follows the API's cursor pagination and -// accumulates every page, rather than returning only the server's first page. -func TestListWalksPages(t *testing.T) { - page := func(items []map[string]any, next string) map[string]any { - return map[string]any{ - "resourceTypes": map[string]any{ - "items": items, - "cursor": map[string]any{ - "next": next, - "previous": "", - }, - }, - } - } - - mock := gqltest.NewClient( - gqltest.RespondWithData(page([]map[string]any{ - {"id": "rt-1", "name": "aws/vpc"}, - {"id": "rt-2", "name": "aws/s3"}, - }, "cursor-2")), - gqltest.RespondWithData(page([]map[string]any{ - {"id": "rt-3", "name": "gcp/bucket"}, - }, "")), - ) - t.Cleanup(api.SetTransportForTest(mock)) +func newMockClient(t *testing.T, responses ...gqltest.Response) *massdriver.Client { + t.Helper() + mock := gqltest.NewClient(responses...) mdClient, err := massdriver.NewClient( massdriver.WithGQLClient(mock), massdriver.WithOrganizationID("test-org"), @@ -101,39 +19,61 @@ func TestListWalksPages(t *testing.T) { if err != nil { t.Fatal(err) } + return mdClient +} - got, err := resourcetype.List(t.Context(), mdClient) +func TestGet(t *testing.T) { + mdClient := newMockClient(t, gqltest.RespondWithData(map[string]any{ + "resourceType": map[string]any{ + "id": "aws-s3-bucket", + "name": "AWS S3 Bucket", + "schema": map[string]any{ + "$id": "https://example.com/schemas/test-schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "A test schema for demonstration purposes.", + }, + }, + })) + + got, err := resourcetype.Get(t.Context(), mdClient, "aws-s3-bucket") if err != nil { t.Fatalf("unexpected error: %v", err) } - - // All three resource types, across both pages, must be accumulated. - wantIDs := []string{"rt-1", "rt-2", "rt-3"} - if len(got) != len(wantIDs) { - t.Fatalf("got %d resource types, want %d (page walk should accumulate all pages): %+v", len(got), len(wantIDs), got) + if got.ID != "aws-s3-bucket" { + t.Errorf("ID = %q, want aws-s3-bucket", got.ID) } - for i, id := range wantIDs { - if got[i].ID != id { - t.Errorf("resource type %d: got id %q, want %q", i, got[i].ID, id) - } + if got.Name != "AWS S3 Bucket" { + t.Errorf("Name = %q, want AWS S3 Bucket", got.Name) } - - // Two requests, each carrying an explicit page-size limit (a null cursor - // 500s the server). The first has no `next`; the second carries the prior - // page's next cursor. - reqs := mock.Requests() - if len(reqs) != 2 { - t.Fatalf("got %d requests, want 2 (should follow cursor.next)", len(reqs)) + if _, ok := got.Schema["$id"]; !ok { + t.Errorf("Schema should carry the resolved JSON schema, got %v", got.Schema) } - cursor1, ok := reqs[0].Variables["cursor"].(map[string]any) - if !ok || cursor1["limit"] == nil || cursor1["next"] != nil { - t.Errorf("first request should send a limit and no next, got %v", reqs[0].Variables["cursor"]) +} + +// TestList verifies List sources from the OCI-repo catalog filtered to +// resource-type artifacts and maps each repo into a ResourceType. +func TestList(t *testing.T) { + mdClient := newMockClient(t, gqltest.RespondWithData(map[string]any{ + "ociRepos": map[string]any{ + "cursor": map[string]any{}, + "items": []map[string]any{ + {"id": "aws-vpc", "name": "aws-vpc", "artifactType": "application/vnd.massdriver.resource-type.v1+json"}, + {"id": "aws-s3", "name": "aws-s3", "artifactType": "application/vnd.massdriver.resource-type.v1+json"}, + }, + }, + })) + + got, err := resourcetype.List(t.Context(), mdClient) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - cursor2, ok := reqs[1].Variables["cursor"].(map[string]any) - if !ok || cursor2["next"] != "cursor-2" { - t.Errorf("second request should carry next=cursor-2, got %v", reqs[1].Variables["cursor"]) + if len(got) != 2 { + t.Fatalf("got %d resource types, want 2: %+v", len(got), got) } - if pending := mock.Pending(); pending != 0 { - t.Errorf("expected all queued responses consumed, %d pending", pending) + wantIDs := []string{"aws-vpc", "aws-s3"} + for i, id := range wantIDs { + if got[i].ID != id || got[i].Name != id { + t.Errorf("resource type %d: got id=%q name=%q, want %q", i, got[i].ID, got[i].Name, id) + } } } 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) - } - }) - } -}