diff --git a/Makefile b/Makefile index 03e47bd4..b2709fa2 100644 --- a/Makefile +++ b/Makefile @@ -2,47 +2,30 @@ INSTALL_PATH ?= ~/bin GIT_SHA := $(shell git log -1 --pretty=format:"%H") LD_FLAGS := "-X github.com/massdriver-cloud/mass/internal/version.version=dev -X github.com/massdriver-cloud/mass/internal/version.gitSHA=local-dev-${GIT_SHA}" -MASSDRIVER_PATH?=../massdriver -MKFILE_PATH := $(abspath $(lastword $(MAKEFILE_LIST))) -MKFILE_DIR := $(dir $(MKFILE_PATH)) -API_DIR := internal/api -SCHEMA_URL ?= https://api.massdriver.cloud/graphql/v2/schema.graphql - .DEFAULT_GOAL := install -all.macos: clean generate install.macos -all.linux: clean generate install.linux - .PHONY: check -check: clean generate test ## Run tests and linter locally - golangci-lint run +check: test lint ## Run tests and linter locally .PHONY: clean clean: - rm -rf ${API_DIR}/schema.graphql - rm -rf ${API_DIR}/zz_generated.go - rm -f ./mass + rm -rf bin/ .PHONY: docs docs: build ./mass docs -.PHONY: generate -generate: - curl -s ${SCHEMA_URL} -o ${API_DIR}/schema.graphql - cd ${API_DIR} && go generate - .PHONY: test test: go test ./... -cover -bin: - mkdir bin - .PHONY: lint lint: golangci-lint run +bin: + mkdir bin + .PHONY: build build: @if [ "$$(uname -s)" = "Darwin" ]; then \ diff --git a/cmd/bundle.go b/cmd/bundle.go index e6beecc3..da4f3fcc 100644 --- a/cmd/bundle.go +++ b/cmd/bundle.go @@ -11,21 +11,23 @@ import ( "fmt" "os" "path/filepath" - "slices" + "sort" "strings" "text/template" "time" "github.com/charmbracelet/glamour" "github.com/massdriver-cloud/mass/docs/helpdocs" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/bundle" "github.com/massdriver-cloud/mass/internal/cli" cmdbundle "github.com/massdriver-cloud/mass/internal/commands/bundle" "github.com/massdriver-cloud/mass/internal/params" "github.com/massdriver-cloud/mass/internal/prettylogs" + "github.com/massdriver-cloud/mass/internal/resourcetype" "github.com/massdriver-cloud/mass/internal/templates" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" + "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" ) @@ -196,12 +198,12 @@ func runBundleCreate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - return createOciRepoCommon(ctx, mdClient, name, "bundle", attrs) + return createOciRepoCommon(ctx, mdClient, name, string(ocirepos.ArtifactTypeBundle), attrs) } func runBundleTemplateList(cmd *cobra.Command, args []string) error { @@ -268,12 +270,12 @@ func runBundleNew(input *bundleNew) error { var runErr error if input.name == "" || input.templateName == "" { // run the interactive prompt - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - resourceTypes, listErr := api.ListResourceTypes(ctx, mdClient, nil) + resourceTypes, listErr := resourcetype.List(ctx, mdClient) if listErr != nil { return fmt.Errorf("error fetching resource types: %w", listErr) } @@ -284,7 +286,7 @@ func runBundleNew(input *bundleNew) error { resourceTypeNames[i] += " (" + rt.Name + ")" } } - slices.Sort(resourceTypeNames) + sort.Strings(resourceTypeNames) templateData, runErr = runBundleNewInteractive(input.outputDir, resourceTypeNames) if runErr != nil { @@ -338,9 +340,9 @@ func runBundleBuild(cmd *cobra.Command, args []string) error { return err } - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } return cmdbundle.RunBuild(bundleDirectory, unmarshalledBundle, mdClient) @@ -372,12 +374,12 @@ func runBundleLint(cmd *cobra.Command, args []string) error { return err } - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - err = unmarshalledBundle.DereferenceSchemas(bundleDirectory, mdClient) + err = unmarshalledBundle.DereferenceSchemas(bundleDirectory, resourcetype.NewMassdriverResolver(mdClient)) if err != nil { return err } @@ -427,12 +429,12 @@ func runBundlePublish(cmd *cobra.Command, args []string) error { return err } - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - err = unmarshalledBundle.Build(bundleDirectory, mdClient) + err = unmarshalledBundle.Build(bundleDirectory, resourcetype.NewMassdriverResolver(mdClient)) if err != nil { return err } @@ -483,9 +485,9 @@ func runBundlePull(cmd *cobra.Command, args []string) error { } } - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return mdClientErr + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } pullErr := cmdbundle.RunPull(ctx, mdClient, bundleName, version, directory) @@ -499,33 +501,28 @@ func runBundlePull(cmd *cobra.Command, args []string) error { func runBundleList(input *bundleList) error { ctx := context.Background() - mdClient, err := client.New() + mdClient, err := massdriver.NewClient() if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } - filter := &api.OciReposFilter{ - ArtifactType: "application/vnd.massdriver.bundle.v1+json", + listInput := ocirepos.ListInput{ + ArtifactType: ocirepos.ArtifactTypeBundle, Search: input.search, + NameEquals: input.name, } - if input.name != "" { - filter.Name = &api.OciRepoNameFilter{Eq: input.name} - } - - var sort *api.OciReposSort if input.sortField != "" { - order := api.SortOrderAsc + listInput.SortOrder = ocirepos.SortAsc if strings.EqualFold(input.sortOrder, "desc") { - order = api.SortOrderDesc + listInput.SortOrder = ocirepos.SortDesc } - field := api.OciReposSortFieldName + listInput.SortBy = ocirepos.SortByName if strings.EqualFold(input.sortField, "created_at") { - field = api.OciReposSortFieldCreatedAt + listInput.SortBy = ocirepos.SortByCreatedAt } - sort = &api.OciReposSort{Field: field, Order: order} } - repos, err := api.ListOciRepos(ctx, mdClient, filter, sort) + repos, err := mdClient.OciRepos.List(ctx, listInput) if err != nil { return fmt.Errorf("failed to list bundles: %w", err) } @@ -540,15 +537,7 @@ func runBundleList(input *bundleList) error { case "table": tbl := cli.NewTable("Name", "Latest", "Created At") for _, repo := range repos { - latest := "" - for _, rc := range repo.ReleaseChannels { - if rc.Name == "latest" { - latest = rc.Tag - break - } - } - createdAt := repo.CreatedAt.Format("2006-01-02 15:04:05") - tbl.AddRow(repo.Name, latest, createdAt) + tbl.AddRow(repo.Name, repo.LatestTag, repo.CreatedAt.Format("2006-01-02 15:04:05")) } tbl.Print() default: @@ -572,25 +561,25 @@ func runBundleGet(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - bundle, err := api.GetBundle(ctx, mdClient, bundleID) + b, err := mdClient.Bundles.Get(ctx, bundleID) if err != nil { return err } switch outputFormat { case "json": - jsonBytes, marshalErr := json.MarshalIndent(bundle, "", " ") + jsonBytes, marshalErr := json.MarshalIndent(b, "", " ") if marshalErr != nil { return fmt.Errorf("failed to marshal bundle to JSON: %w", marshalErr) } fmt.Println(string(jsonBytes)) case "text": - err = renderBundle(bundle, mdClient) + err = renderBundle(b, mdClient) if err != nil { return err } @@ -601,7 +590,7 @@ func runBundleGet(cmd *cobra.Command, args []string) error { return nil } -func renderBundle(b *api.Bundle, mdClient *client.Client) error { +func renderBundle(b *types.Bundle, mdClient *massdriver.Client) error { tmplBytes, err := bundleTemplates.ReadFile("templates/bundle.get.md.tmpl") if err != nil { return fmt.Errorf("failed to read template: %w", err) @@ -614,14 +603,10 @@ func renderBundle(b *api.Bundle, mdClient *client.Client) error { // Get app URL for constructing bundle URL ctx := context.Background() - urlHelper, urlErr := api.NewURLHelper(ctx, mdClient) - bundleURL := "" - if urlErr == nil { - bundleURL = urlHelper.BundleURL(b.Name, b.Version) - } + bundleURL := mdClient.URLs.Helper(ctx).BundleURL(b.Name, b.Version) data := struct { - *api.Bundle + *types.Bundle URL string FormatTime func(time.Time) string }{ diff --git a/cmd/component.go b/cmd/component.go index bd41a52d..95d3f44b 100644 --- a/cmd/component.go +++ b/cmd/component.go @@ -5,11 +5,11 @@ import ( "fmt" "github.com/massdriver-cloud/mass/docs/helpdocs" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/cli" "github.com/massdriver-cloud/mass/internal/commands/component" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/components" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" "github.com/spf13/cobra" ) @@ -114,20 +114,21 @@ func runComponentAdd(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - input := api.AddComponentInput{ - Id: shortID, + input := components.AddInput{ + OciRepoName: ociRepoName, + ID: shortID, Name: name, Description: description, Attributes: cli.AttributesToAnyMap(attrs), } - comp, addErr := api.AddComponent(ctx, mdClient, projectID, ociRepoName, input) - if addErr != nil { - return addErr + comp, err := mdClient.Components.Add(ctx, projectID, input) + if err != nil { + return err } fmt.Printf("✅ Component `%s` added to project `%s`\n", comp.ID, projectID) @@ -153,14 +154,14 @@ func runComponentUpdate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - current, getErr := api.GetComponent(ctx, mdClient, componentID) - if getErr != nil { - return fmt.Errorf("error getting component: %w", getErr) + current, err := mdClient.Components.Get(ctx, componentID) + if err != nil { + return fmt.Errorf("error getting component: %w", err) } if !cmd.Flags().Changed("name") { @@ -173,16 +174,16 @@ func runComponentUpdate(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("attributes") { attributes = cli.AttributesToAnyMap(attrs) } else { - attributes = cli.StringMapToAnyMap(current.Attributes) + attributes = current.Attributes } - input := api.UpdateComponentInput{ + input := components.UpdateInput{ Name: name, Description: description, Attributes: attributes, } - updated, err := api.UpdateComponent(ctx, mdClient, componentID, input) + updated, err := mdClient.Components.Update(ctx, componentID, input) if err != nil { return err } @@ -197,12 +198,12 @@ func runComponentRemove(cmd *cobra.Command, args []string) error { componentID := args[0] cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - comp, err := api.RemoveComponent(ctx, mdClient, componentID) + comp, err := mdClient.Components.Remove(ctx, componentID) if err != nil { return err } @@ -234,22 +235,22 @@ func runComponentLink(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - input := api.LinkComponentsInput{ - FromComponentId: fromComponentID, + input := components.AddLinkInput{ + FromComponentID: fromComponentID, FromField: fromField, FromVersion: fromVersion, - ToComponentId: toComponentID, + ToComponentID: toComponentID, ToField: toField, ToVersion: toVersion, } - link, linkErr := api.LinkComponents(ctx, mdClient, input) - if linkErr != nil { - return linkErr + link, err := mdClient.Components.AddLink(ctx, input) + if err != nil { + return err } fmt.Printf("✅ Linked `%s.%s` → `%s.%s` (id: %s)\n", fromComponentID, link.FromField, toComponentID, link.ToField, link.ID) @@ -275,25 +276,24 @@ func runComponentUnlink(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - links, err := api.ListLinks(ctx, mdClient, projectID, &api.LinksFilter{ - FromComponentId: &api.IdFilter{Eq: fromComponentID}, - ToComponentId: &api.IdFilter{Eq: toComponentID}, - }) + // Links no longer have a dedicated GraphQL list query; fetch the project's + // full link set and filter client-side. + proj, err := mdClient.Projects.Get(ctx, projectID) if err != nil { return err } - target, err := component.FindLink(links, fromField, toField) + target, err := component.FindLink(proj.Links, fromComponentID, fromField, toComponentID, toField) if err != nil { return fmt.Errorf("no link found from `%s.%s` to `%s.%s`", fromComponentID, fromField, toComponentID, toField) } - if _, err := api.UnlinkComponents(ctx, mdClient, target.ID); err != nil { + if _, err := mdClient.Components.RemoveLink(ctx, target.ID); err != nil { return err } diff --git a/cmd/deployment.go b/cmd/deployment.go index 64e5f453..9097f2cd 100644 --- a/cmd/deployment.go +++ b/cmd/deployment.go @@ -11,14 +11,14 @@ import ( "os/signal" "syscall" "text/template" - "time" "github.com/charmbracelet/glamour" "github.com/massdriver-cloud/mass/docs/helpdocs" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/cli" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/deployments" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" "github.com/spf13/cobra" ) @@ -81,12 +81,12 @@ func runDeploymentGet(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - deployment, err := api.GetDeployment(ctx, mdClient, deploymentID) + deployment, err := mdClient.Deployments.Get(ctx, deploymentID) if err != nil { return err } @@ -117,128 +117,65 @@ func runDeploymentList(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - filter := &api.DeploymentsFilter{ - InstanceId: &api.IdFilter{Eq: instanceID}, - } - sort := &api.DeploymentsSort{ - Field: api.DeploymentsSortFieldCreatedAt, - Order: api.SortOrderDesc, - } - deployments, err := api.ListDeployments(ctx, mdClient, filter, sort, limit) - if err != nil { - return err + // SDK's List auto-paginates without a total cap; we want a total cap, so + // drive Iter and stop after `limit` items. + listInput := deployments.ListInput{ + InstanceID: instanceID, + SortBy: deployments.SortByCreatedAt, + SortOrder: deployments.SortDesc, } tbl := cli.NewTable("ID", "Action", "Status", "Version", "Created At", "By", "Message") - for _, d := range deployments { + count := 0 + for d, iterErr := range mdClient.Deployments.Iter(ctx, listInput) { + if iterErr != nil { + return iterErr + } + if limit > 0 && count >= limit { + break + } tbl.AddRow(d.ID, d.Action, d.Status, d.Version, d.CreatedAt, d.DeployedBy, cli.TruncateString(d.Message, 40)) + count++ } tbl.Print() return nil } -// terminalDeploymentStatuses are the deployment lifecycle states past which no -// further logs will be emitted. -var terminalDeploymentStatuses = map[string]struct{}{ - "COMPLETED": {}, - "FAILED": {}, - "ABORTED": {}, - "REJECTED": {}, -} - func runDeploymentLogs(cmd *cobra.Command, args []string) error { - ctx := context.Background() deploymentID := args[0] cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) - } - - deployment, err := api.GetDeployment(ctx, mdClient, deploymentID) - if err != nil { - return fmt.Errorf("error getting deployment: %w", err) - } - - if _, terminal := terminalDeploymentStatuses[deployment.Status]; terminal { - // Deployment finished; just dump the stored log batches and exit. - return printDeploymentBackfill(ctx, mdClient, deploymentID) - } - - // Still running — open the subscription first so we don't miss batches that - // land between the backfill fetch and the subscribe call. The buffered - // channel inside the subscription will hold them until we drain it. - streamCtx, cancel := signalContext(ctx) + ctx, cancel := signalContext(context.Background()) defer cancel() - stream, closeStream, err := api.SubscribeDeploymentLogs(streamCtx, mdClient, deploymentID) + mdClient, err := massdriver.NewClient() if err != nil { - // If streaming setup fails (e.g., basic-auth instead of PAT) fall back - // to whatever's already stored. - fmt.Fprintf(os.Stderr, "warning: log streaming unavailable: %v\n", err) - return printDeploymentBackfill(ctx, mdClient, deploymentID) + return fmt.Errorf("error initializing massdriver client: %w", err) } - defer closeStream() - - if err := printDeploymentBackfill(ctx, mdClient, deploymentID); err != nil { - return err - } - - // Poll status in the background; close the stream when we hit terminal so - // the range loop below unblocks. - statusErrCh := make(chan error, 1) - go func() { - statusErrCh <- pollUntilTerminal(streamCtx, mdClient, deploymentID, closeStream) - }() - for log := range stream { - fmt.Fprint(os.Stdout, log.Message) - } - - if err := <-statusErrCh; err != nil && !errors.Is(err, context.Canceled) { - return err - } - return nil -} - -func printDeploymentBackfill(ctx context.Context, mdClient *client.Client, deploymentID string) error { - logs, err := api.GetDeploymentLogs(ctx, mdClient, deploymentID) - if err != nil { - return fmt.Errorf("error getting deployment logs: %w", err) - } - for _, log := range logs { - fmt.Fprint(os.Stdout, log.Message) - } - return nil -} - -// pollUntilTerminal polls the deployment status every second until it -// reaches a terminal state, then invokes done() to tear down the streaming -// subscription. Returns ctx.Err() if cancelled before terminal. -func pollUntilTerminal(ctx context.Context, mdClient *client.Client, deploymentID string, done func()) error { - const pollInterval = 1 * time.Second - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(pollInterval): - } - dep, err := api.GetDeployment(ctx, mdClient, deploymentID) + // TailLogs collapses backfill + terminal-check + live streaming into one call. + tailErr := mdClient.Deployments.TailLogs(ctx, deploymentID, os.Stdout) + if errors.Is(tailErr, deployments.ErrStreamingRequiresPAT) { + // Fall back to a one-shot static-log dump so the user still gets the + // available history. Streaming would require a personal access token. + fmt.Fprintln(os.Stderr, "warning: log streaming requires a personal access token (mds_*/md_*); showing static logs instead") + backfill, err := mdClient.Deployments.GetLogs(ctx, deploymentID) if err != nil { - return err - } - if _, terminal := terminalDeploymentStatuses[dep.Status]; terminal { - done() - return nil + return fmt.Errorf("error getting deployment logs: %w", err) } + fmt.Fprint(os.Stdout, backfill) + return nil + } + if tailErr != nil && !errors.Is(tailErr, context.Canceled) { + return tailErr } + return nil } // signalContext returns a derived context that cancels on SIGINT/SIGTERM, so @@ -247,7 +184,8 @@ func signalContext(parent context.Context) (context.Context, context.CancelFunc) return signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM) } -func renderDeployment(deployment *api.Deployment) error { +//nolint:dupl // parallel template-render shape with renderInstance; consolidating would couple unrelated commands +func renderDeployment(deployment *types.Deployment) error { tmplBytes, err := deploymentTemplates.ReadFile("templates/deployment.get.md.tmpl") if err != nil { return fmt.Errorf("failed to read template: %w", err) @@ -258,8 +196,20 @@ func renderDeployment(deployment *api.Deployment) error { return fmt.Errorf("failed to parse template: %w", err) } + paramsJSON := "{}" + if deployment.Params != nil { + if b, marshalErr := json.MarshalIndent(deployment.Params, "", " "); marshalErr == nil { + paramsJSON = string(b) + } + } + + data := struct { + *types.Deployment + ParamsJSON string + }{Deployment: deployment, ParamsJSON: paramsJSON} + var buf bytes.Buffer - if renderErr := tmpl.Execute(&buf, deployment); renderErr != nil { + if renderErr := tmpl.Execute(&buf, data); renderErr != nil { return fmt.Errorf("failed to execute template: %w", renderErr) } diff --git a/cmd/environment.go b/cmd/environment.go index 96c4906f..76a9bd13 100644 --- a/cmd/environment.go +++ b/cmd/environment.go @@ -11,11 +11,12 @@ import ( "github.com/charmbracelet/glamour" "github.com/massdriver-cloud/mass/docs/helpdocs" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/cli" "github.com/massdriver-cloud/mass/internal/commands/environment" - - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/environments" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/instances" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" "github.com/spf13/cobra" ) @@ -102,9 +103,9 @@ func runEnvironmentExport(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } return environment.RunExport(ctx, mdClient, environmentID) @@ -121,26 +122,25 @@ func runEnvironmentGet(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - environment, err := api.GetEnvironment(ctx, mdClient, environmentID) + env, err := mdClient.Environments.Get(ctx, environmentID) if err != nil { return err } switch outputFormat { case "json": - jsonBytes, marshalErr := json.MarshalIndent(environment, "", " ") + jsonBytes, marshalErr := json.MarshalIndent(env, "", " ") if marshalErr != nil { return fmt.Errorf("failed to marshal environment to JSON: %w", marshalErr) } fmt.Println(string(jsonBytes)) case "text": - err = renderEnvironment(environment) - if err != nil { + if err := renderEnvironment(ctx, mdClient, env); err != nil { return err } default: @@ -157,23 +157,19 @@ func runEnvironmentList(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) - } - - filter := api.EnvironmentsFilter{ - ProjectId: &api.IdFilter{Eq: projectID}, + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - environments, err := api.ListEnvironments(ctx, mdClient, &filter) + envs, err := mdClient.Environments.List(ctx, environments.ListInput{ProjectID: projectID}) if err != nil { return err } tbl := cli.NewTable("ID", "Name", "Description", "Monthly $", "Daily $") - for _, env := range environments { + for _, env := range envs { monthly := "" daily := "" if env.Cost.MonthlyAverage.Amount != nil { @@ -191,7 +187,7 @@ func runEnvironmentList(cmd *cobra.Command, args []string) error { return nil } -func renderEnvironment(environment *api.Environment) error { +func renderEnvironment(ctx context.Context, mdClient *massdriver.Client, env *environments.Environment) error { tmplBytes, err := environmentTemplates.ReadFile("templates/environment.get.md.tmpl") if err != nil { return fmt.Errorf("failed to read template: %w", err) @@ -202,8 +198,20 @@ func renderEnvironment(environment *api.Environment) error { return fmt.Errorf("failed to parse template: %w", err) } + // Instances aren't embedded on the environment record returned by + // Environments.Get; fetch them separately so the template can render them. + insts, err := mdClient.Instances.List(ctx, instances.ListInput{EnvironmentID: env.ID}) + if err != nil { + return fmt.Errorf("failed to list instances: %w", err) + } + + data := struct { + *types.Environment + Instances []types.Instance + }{Environment: env, Instances: insts} + var buf bytes.Buffer - if renderErr := tmpl.Execute(&buf, environment); renderErr != nil { + if renderErr := tmpl.Execute(&buf, data); renderErr != nil { return fmt.Errorf("failed to execute template: %w", renderErr) } @@ -252,28 +260,25 @@ func runEnvironmentCreate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - input := api.CreateEnvironmentInput{ - Id: envID, + input := environments.CreateInput{ + ID: envID, Name: name, Description: description, Attributes: cli.AttributesToAnyMap(attrs), } - env, err := api.CreateEnvironment(ctx, mdClient, projectID, input) + env, err := mdClient.Environments.Create(ctx, projectID, input) if err != nil { return err } fmt.Printf("✅ Environment `%s` created successfully\n", fullID) - urlHelper, urlErr := api.NewURLHelper(ctx, mdClient) - if urlErr == nil { - fmt.Printf("🔗 %s\n", urlHelper.EnvironmentURL(env.ID)) - } + fmt.Printf("🔗 %s\n", mdClient.URLs.Helper(ctx).EnvironmentURL(env.ID)) return nil } @@ -297,14 +302,14 @@ func runEnvironmentUpdate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - current, getErr := api.GetEnvironment(ctx, mdClient, environmentID) - if getErr != nil { - return fmt.Errorf("error getting environment: %w", getErr) + current, err := mdClient.Environments.Get(ctx, environmentID) + if err != nil { + return fmt.Errorf("error getting environment: %w", err) } if !cmd.Flags().Changed("name") { @@ -317,25 +322,22 @@ func runEnvironmentUpdate(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("attributes") { attributes = cli.AttributesToAnyMap(attrs) } else { - attributes = cli.StringMapToAnyMap(current.Attributes) + attributes = current.Attributes } - input := api.UpdateEnvironmentInput{ + input := environments.UpdateInput{ Name: name, Description: description, Attributes: attributes, } - updated, err := api.UpdateEnvironment(ctx, mdClient, environmentID, input) + updated, err := mdClient.Environments.Update(ctx, environmentID, input) if err != nil { return err } fmt.Printf("✅ Environment `%s` updated\n", updated.ID) - urlHelper, urlErr := api.NewURLHelper(ctx, mdClient) - if urlErr == nil { - fmt.Printf("🔗 %s\n", urlHelper.EnvironmentURL(updated.ID)) - } + fmt.Printf("🔗 %s\n", mdClient.URLs.Helper(ctx).EnvironmentURL(updated.ID)) return nil } @@ -347,26 +349,22 @@ func runEnvironmentDefault(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - _, err := api.SetEnvironmentDefault(ctx, mdClient, environmentID, resourceID) - if err != nil { - return err + if _, setErr := mdClient.Environments.SetDefault(ctx, environmentID, resourceID); setErr != nil { + return setErr } - environment, err := api.GetEnvironment(ctx, mdClient, environmentID) + env, err := mdClient.Environments.Get(ctx, environmentID) if err != nil { return fmt.Errorf("failed to get environment: %w", err) } - fmt.Printf("✅ Environment `%s` default connection set successfully\n", environment.ID) - urlHelper, urlErr := api.NewURLHelper(ctx, mdClient) - if urlErr == nil { - fmt.Printf("🔗 %s\n", urlHelper.EnvironmentURL(environment.ID)) - } + fmt.Printf("✅ Environment `%s` default connection set successfully\n", env.ID) + fmt.Printf("🔗 %s\n", mdClient.URLs.Helper(ctx).EnvironmentURL(env.ID)) return nil } diff --git a/cmd/instance.go b/cmd/instance.go index dbe20542..5d67c170 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -12,13 +12,15 @@ import ( "text/template" "github.com/massdriver-cloud/mass/docs/helpdocs" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/cli" "github.com/massdriver-cloud/mass/internal/commands/instance" "github.com/massdriver-cloud/mass/internal/files" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/charmbracelet/glamour" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/deployments" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/instances" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" "github.com/spf13/cobra" ) @@ -72,12 +74,11 @@ func NewCmdInstance() *cobra.Command { instanceVersionCmd := &cobra.Command{ Use: `version @`, Short: "Set instance version", - Example: `mass instance version api-prod-db@latest --release-channel development`, + Example: `mass instance version api-prod-db@latest`, Long: helpdocs.MustRender("instance/version"), Args: cobra.ExactArgs(1), RunE: runInstanceVersion, } - instanceVersionCmd.Flags().String("release-channel", "stable", "Release strategy (stable or development)") instanceDestroyCmd := &cobra.Command{ Use: `destroy --`, @@ -124,26 +125,25 @@ func runInstanceGet(cmd *cobra.Command, args []string) error { instanceID := args[0] - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - instance, err := api.GetInstance(ctx, mdClient, instanceID) + inst, err := mdClient.Instances.Get(ctx, instanceID) if err != nil { return err } switch outputFormat { case "json": - jsonBytes, marshalErr := json.MarshalIndent(instance, "", " ") + jsonBytes, marshalErr := json.MarshalIndent(inst, "", " ") if marshalErr != nil { return fmt.Errorf("failed to marshal instance to JSON: %w", marshalErr) } fmt.Println(string(jsonBytes)) case "text": - err = renderInstance(instance) - if err != nil { + if err := renderInstance(inst); err != nil { return err } default: @@ -153,7 +153,8 @@ func runInstanceGet(cmd *cobra.Command, args []string) error { return nil } -func renderInstance(instance *api.Instance) error { +//nolint:dupl // parallel template-render shape with renderDeployment; consolidating would couple unrelated commands +func renderInstance(inst *types.Instance) error { tmplBytes, err := instanceTemplates.ReadFile("templates/instance.get.md.tmpl") if err != nil { return fmt.Errorf("failed to read template: %w", err) @@ -164,8 +165,20 @@ func renderInstance(instance *api.Instance) error { return fmt.Errorf("failed to parse template: %w", err) } + paramsJSON := "{}" + if inst.Params != nil { + if b, marshalErr := json.MarshalIndent(inst.Params, "", " "); marshalErr == nil { + paramsJSON = string(b) + } + } + + data := struct { + *types.Instance + ParamsJSON string + }{Instance: inst, ParamsJSON: paramsJSON} + var buf bytes.Buffer - if renderErr := tmpl.Execute(&buf, instance); renderErr != nil { + if renderErr := tmpl.Execute(&buf, data); renderErr != nil { return fmt.Errorf("failed to execute template: %w", renderErr) } @@ -183,15 +196,14 @@ func renderInstance(instance *api.Instance) error { return nil } -//nolint:gocognit // sequential flag parsing and dispatch, not branching logic func runInstanceDeploy(cmd *cobra.Command, args []string) error { ctx := context.Background() name := args[0] - action := api.DeploymentActionProvision + action := deployments.ActionProvision if cmd.Name() == "destroy" { - action = api.DeploymentActionDecommission + action = deployments.ActionDecommission } msg, err := cmd.Flags().GetString("message") @@ -231,7 +243,7 @@ func runInstanceDeploy(cmd *cobra.Command, args []string) error { opts.Params = params } - if action == api.DeploymentActionDecommission { + if action == deployments.ActionDecommission { force, forceErr := cmd.Flags().GetBool("force") if forceErr != nil { return forceErr @@ -246,24 +258,22 @@ func runInstanceDeploy(cmd *cobra.Command, args []string) error { return nil } } - cmd.SilenceUsage = true } - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + cmd.SilenceUsage = true + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - if _, err = instance.RunDeploy(ctx, mdClient, name, opts); err != nil { + if _, err = instance.RunDeploy(ctx, instance.NewDeployAPI(mdClient), name, opts); err != nil { return err } - if action == api.DeploymentActionDecommission { + if action == deployments.ActionDecommission { fmt.Printf("✅ Instance `%s` decommission started\n", name) - urlHelper, urlErr := api.NewURLHelper(ctx, mdClient) - if urlErr == nil { - fmt.Printf("🔗 %s\n", urlHelper.InstanceURL(name)) - } + fmt.Printf("🔗 %s\n", mdClient.URLs.Helper(ctx).InstanceURL(name)) } return nil @@ -290,9 +300,9 @@ func runInstanceExport(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } exportErr := instance.RunExport(ctx, mdClient, instanceID) @@ -316,42 +326,18 @@ func runInstanceVersion(cmd *cobra.Command, args []string) error { instanceID := parts[0] version := parts[1] - releaseChannel, err := cmd.Flags().GetString("release-channel") + mdClient, err := massdriver.NewClient() if err != nil { - return err - } - - // Convert release channel to ReleaseStrategy enum value - var releaseStrategy api.ReleaseStrategy - switch releaseChannel { - case "development": - releaseStrategy = api.ReleaseStrategyDevelopment - case "stable": - releaseStrategy = api.ReleaseStrategyStable - default: - return fmt.Errorf("invalid release-channel: must be 'stable' or 'development', got '%s'", releaseChannel) - } - - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) - } - - input := api.UpdateInstanceInput{ - Version: version, - ReleaseStrategy: releaseStrategy, + return fmt.Errorf("error initializing massdriver client: %w", err) } - updatedInstance, err := api.UpdateInstance(ctx, mdClient, instanceID, input) + updatedInstance, err := mdClient.Instances.Update(ctx, instanceID, instances.UpdateInput{Version: version}) if err != nil { return err } fmt.Printf("✅ Instance `%s` version set successfully\n", updatedInstance.ID) - urlHelper, urlErr := api.NewURLHelper(ctx, mdClient) - if urlErr == nil { - fmt.Printf("🔗 %s\n", urlHelper.InstanceURL(updatedInstance.ID)) - } + fmt.Printf("🔗 %s\n", mdClient.URLs.Helper(ctx).InstanceURL(updatedInstance.ID)) return nil } @@ -363,24 +349,28 @@ func runInstanceList(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) - } - - filter := api.InstancesFilter{ - EnvironmentId: &api.IdFilter{Eq: environmentID}, + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - instances, err := api.ListInstances(ctx, mdClient, &filter) + insts, err := mdClient.Instances.List(ctx, instances.ListInput{EnvironmentID: environmentID}) if err != nil { return err } tbl := cli.NewTable("ID", "Name", "Bundle", "Status") - for _, instance := range instances { - tbl.AddRow(instance.ID, instance.Component.Name, instance.Bundle.Name, instance.Status) + for _, inst := range insts { + componentName := "" + if inst.Component != nil { + componentName = inst.Component.Name + } + bundleName := "" + if inst.Bundle != nil { + bundleName = inst.Bundle.Name + } + tbl.AddRow(inst.ID, componentName, bundleName, inst.Status) } tbl.Print() diff --git a/cmd/project.go b/cmd/project.go index 39071bd9..889c2f78 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -13,10 +13,10 @@ import ( "github.com/charmbracelet/glamour" "github.com/massdriver-cloud/mass/docs/helpdocs" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/cli" "github.com/massdriver-cloud/mass/internal/commands/project" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/projects" "github.com/spf13/cobra" ) @@ -105,25 +105,25 @@ func runProjectGet(cmd *cobra.Command, args []string) error { return err } - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - project, err := api.GetProject(ctx, mdClient, projectID) + proj, err := mdClient.Projects.Get(ctx, projectID) if err != nil { return err } switch outputFormat { case "json": - jsonBytes, marshalErr := json.MarshalIndent(project, "", " ") + jsonBytes, marshalErr := json.MarshalIndent(proj, "", " ") if marshalErr != nil { return fmt.Errorf("failed to marshal project to JSON: %w", marshalErr) } fmt.Println(string(jsonBytes)) case "text": - err = renderProject(project) + err = renderProject(proj) if err != nil { return err } @@ -141,9 +141,9 @@ func runProjectExport(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } return project.RunExport(ctx, mdClient, projectID) @@ -152,29 +152,29 @@ func runProjectExport(cmd *cobra.Command, args []string) error { func runProjectList(cmd *cobra.Command, args []string) error { ctx := context.Background() - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - projects, err := api.ListProjects(ctx, mdClient) + projectList, err := mdClient.Projects.List(ctx, projects.ListInput{}) if err != nil { return err } tbl := cli.NewTable("ID/Slug", "Name", "Description", "Monthly $", "Daily $") - for _, project := range projects { + for _, p := range projectList { monthly := "" daily := "" - if project.Cost.MonthlyAverage.Amount != nil { - monthly = fmt.Sprintf("%v", *project.Cost.MonthlyAverage.Amount) + if p.Cost.MonthlyAverage.Amount != nil { + monthly = fmt.Sprintf("%v", *p.Cost.MonthlyAverage.Amount) } - if project.Cost.DailyAverage.Amount != nil { - daily = fmt.Sprintf("%v", *project.Cost.DailyAverage.Amount) + if p.Cost.DailyAverage.Amount != nil { + daily = fmt.Sprintf("%v", *p.Cost.DailyAverage.Amount) } - description := cli.TruncateString(project.Description, 60) - tbl.AddRow(project.ID, project.Name, description, monthly, daily) + description := cli.TruncateString(p.Description, 60) + tbl.AddRow(p.ID, p.Name, description, monthly, daily) } tbl.Print() @@ -182,7 +182,7 @@ func runProjectList(cmd *cobra.Command, args []string) error { return nil } -func renderProject(project *api.Project) error { +func renderProject(p *projects.Project) error { tmplBytes, err := projectTemplates.ReadFile("templates/project.get.md.tmpl") if err != nil { return fmt.Errorf("failed to read template: %w", err) @@ -194,7 +194,7 @@ func renderProject(project *api.Project) error { } var buf bytes.Buffer - if renderErr := tmpl.Execute(&buf, project); renderErr != nil { + if renderErr := tmpl.Execute(&buf, p); renderErr != nil { return fmt.Errorf("failed to execute template: %w", renderErr) } @@ -234,28 +234,25 @@ func runProjectCreate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - input := api.CreateProjectInput{ - Id: id, + input := projects.CreateInput{ + ID: id, Name: name, Description: description, Attributes: cli.AttributesToAnyMap(attrs), } - project, err := api.CreateProject(ctx, mdClient, input) + proj, err := mdClient.Projects.Create(ctx, input) if err != nil { return err } - fmt.Printf("✅ Project `%s` created successfully\n", project.ID) - urlHelper, urlErr := api.NewURLHelper(ctx, mdClient) - if urlErr == nil { - fmt.Printf("🔗 %s\n", urlHelper.ProjectURL(project.ID)) - } + fmt.Printf("✅ Project `%s` created successfully\n", proj.ID) + fmt.Printf("🔗 %s\n", mdClient.URLs.Helper(ctx).ProjectURL(proj.ID)) return nil } @@ -279,16 +276,16 @@ func runProjectUpdate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } // Fetch current state so unset flags retain their existing values rather // than blanking the field at the server. - current, getErr := api.GetProject(ctx, mdClient, projectID) - if getErr != nil { - return fmt.Errorf("error getting project: %w", getErr) + current, err := mdClient.Projects.Get(ctx, projectID) + if err != nil { + return fmt.Errorf("error getting project: %w", err) } if !cmd.Flags().Changed("name") { @@ -301,25 +298,22 @@ func runProjectUpdate(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("attributes") { attributes = cli.AttributesToAnyMap(attrs) } else { - attributes = cli.StringMapToAnyMap(current.Attributes) + attributes = current.Attributes } - input := api.UpdateProjectInput{ + input := projects.UpdateInput{ Name: name, Description: description, Attributes: attributes, } - updated, err := api.UpdateProject(ctx, mdClient, projectID, input) + updated, err := mdClient.Projects.Update(ctx, projectID, input) if err != nil { return err } fmt.Printf("✅ Project `%s` updated\n", updated.ID) - urlHelper, urlErr := api.NewURLHelper(ctx, mdClient) - if urlErr == nil { - fmt.Printf("🔗 %s\n", urlHelper.ProjectURL(updated.ID)) - } + fmt.Printf("🔗 %s\n", mdClient.URLs.Helper(ctx).ProjectURL(updated.ID)) return nil } @@ -334,32 +328,32 @@ func runProjectDelete(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } // Get project details for confirmation - project, getErr := api.GetProject(ctx, mdClient, projectID) - if getErr != nil { - return fmt.Errorf("error getting project: %w", getErr) + proj, err := mdClient.Projects.Get(ctx, projectID) + if err != nil { + return fmt.Errorf("error getting project: %w", err) } // Prompt for confirmation - requires typing the project ID unless --force is used if !force { - fmt.Printf("WARNING: This will permanently delete project `%s` and all its resources.\n", project.ID) - fmt.Printf("Type `%s` to confirm deletion: ", project.ID) + fmt.Printf("WARNING: This will permanently delete project `%s` and all its resources.\n", proj.ID) + fmt.Printf("Type `%s` to confirm deletion: ", proj.ID) reader := bufio.NewReader(os.Stdin) answer, _ := reader.ReadString('\n') answer = strings.TrimSpace(answer) - if answer != project.ID { + if answer != proj.ID { fmt.Println("Deletion cancelled.") return nil } } - deletedProject, err := api.DeleteProject(ctx, mdClient, projectID) + deletedProject, err := mdClient.Projects.Delete(ctx, projectID) if err != nil { return err } diff --git a/cmd/repository.go b/cmd/repository.go index cb3bc5d8..c32a511c 100644 --- a/cmd/repository.go +++ b/cmd/repository.go @@ -14,30 +14,26 @@ import ( "time" "github.com/charmbracelet/glamour" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/cli" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" + "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" ) //go:embed templates/repository.get.md.tmpl var repositoryTemplates embed.FS -// artifactTypeAliases maps friendly names users might type into the canonical -// OCI media type the server stores. The reverse mapping (`mediaTypeLabels`) -// is used for display in `mass repository list`. -var artifactTypeAliases = map[string]string{ - "bundle": "application/vnd.massdriver.bundle.v1+json", +// artifactTypeAliases maps the user-facing --type flag values to the SDK's +// typed artifact-type enum. Today only "bundle" is supported by the platform. +var artifactTypeAliases = map[string]ocirepos.ArtifactType{ + "bundle": ocirepos.ArtifactTypeBundle, } -var mediaTypeLabels = map[string]string{ - "application/vnd.massdriver.bundle.v1+json": "bundle", -} - -// createTypeEnums maps the friendly --type flag values into the -// OciArtifactType enum the createOciRepo mutation expects. -var createTypeEnums = map[string]api.OciArtifactType{ - "bundle": api.OciArtifactTypeBundle, +// artifactTypeLabels is the reverse lookup for table/output rendering — turns +// the SDK's typed enum back into the friendly name the user typed. +var artifactTypeLabels = map[ocirepos.ArtifactType]string{ + ocirepos.ArtifactTypeBundle: "bundle", } // NewCmdRepository returns a cobra command for managing OCI repositories. @@ -122,19 +118,17 @@ type repositoryListInput struct { func runRepositoryList(input *repositoryListInput) error { ctx := context.Background() - mdClient, err := client.New() + mdClient, err := massdriver.NewClient() if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } - filter, filterErr := buildOciReposFilter(input) - if filterErr != nil { - return filterErr + listInput, buildErr := buildOciReposListInput(input) + if buildErr != nil { + return buildErr } - sort := buildOciReposSort(input.sortField, input.sortOrder) - - repos, err := api.ListOciRepos(ctx, mdClient, filter, sort) + repos, err := mdClient.OciRepos.List(ctx, listInput) if err != nil { return fmt.Errorf("failed to list repositories: %w", err) } @@ -149,14 +143,7 @@ func runRepositoryList(input *repositoryListInput) error { case "table": tbl := cli.NewTable("Name", "Type", "Latest", "Created At") for _, repo := range repos { - latest := "" - for _, rc := range repo.ReleaseChannels { - if rc.Name == "latest" { - latest = rc.Tag - break - } - } - tbl.AddRow(repo.Name, artifactTypeLabel(repo.ArtifactType), latest, repo.CreatedAt.Format("2006-01-02 15:04:05")) + tbl.AddRow(repo.Name, artifactTypeLabel(repo.ArtifactType), repo.LatestTag, repo.CreatedAt.Format("2006-01-02 15:04:05")) } tbl.Print() default: @@ -166,69 +153,56 @@ func runRepositoryList(input *repositoryListInput) error { return nil } -func buildOciReposFilter(input *repositoryListInput) (*api.OciReposFilter, error) { - filter := &api.OciReposFilter{} - hasFilter := false +func buildOciReposListInput(input *repositoryListInput) (ocirepos.ListInput, error) { + out := ocirepos.ListInput{ + Search: input.search, + } if input.kind != "" { - mediaType, resolveErr := resolveArtifactType(input.kind) + artifactType, resolveErr := resolveArtifactType(input.kind) if resolveErr != nil { - return nil, resolveErr + return out, resolveErr } - filter.ArtifactType = mediaType - hasFilter = true - } - if input.search != "" { - filter.Search = input.search - hasFilter = true + out.ArtifactType = artifactType } + switch { case input.name != "" && input.prefix != "": - return nil, errors.New("--name and --prefix are mutually exclusive") + return out, errors.New("--name and --prefix are mutually exclusive") case input.name != "": - filter.Name = &api.OciRepoNameFilter{Eq: input.name} - hasFilter = true + out.NameEquals = input.name case input.prefix != "": - filter.Name = &api.OciRepoNameFilter{StartsWith: input.prefix} - hasFilter = true + out.NameStartsWith = input.prefix } - if !hasFilter { - return nil, nil //nolint:nilnil // explicit nil filter is the no-filter signal to the API - } - return filter, nil -} -func buildOciReposSort(sortField, sortOrder string) *api.OciReposSort { - if sortField == "" { - return nil - } - field := api.OciReposSortFieldName - if strings.EqualFold(sortField, "created_at") { - field = api.OciReposSortFieldCreatedAt - } - order := api.SortOrderAsc - if strings.EqualFold(sortOrder, "desc") { - order = api.SortOrderDesc + if input.sortField != "" { + field := ocirepos.SortByName + if strings.EqualFold(input.sortField, "created_at") { + field = ocirepos.SortByCreatedAt + } + order := ocirepos.SortAsc + if strings.EqualFold(input.sortOrder, "desc") { + order = ocirepos.SortDesc + } + out.SortBy = field + out.SortOrder = order } - return &api.OciReposSort{Field: field, Order: order} + + return out, nil } -func resolveArtifactType(s string) (string, error) { - if mediaType, ok := artifactTypeAliases[strings.ToLower(s)]; ok { - return mediaType, nil - } - if strings.Contains(s, "/") { - // already a media type - return s, nil +func resolveArtifactType(s string) (ocirepos.ArtifactType, error) { + if at, ok := artifactTypeAliases[strings.ToLower(s)]; ok { + return at, nil } return "", fmt.Errorf("unknown artifact type %q (valid: bundle)", s) } -func artifactTypeLabel(mediaType string) string { - if label, ok := mediaTypeLabels[mediaType]; ok { +func artifactTypeLabel(at ocirepos.ArtifactType) string { + if label, ok := artifactTypeLabels[at]; ok { return label } - return mediaType + return string(at) } func runRepositoryGet(cmd *cobra.Command, args []string) error { @@ -246,14 +220,14 @@ func runRepositoryGet(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - repo, getErr := api.GetOciRepo(ctx, mdClient, name) - if getErr != nil { - return getErr + repo, err := mdClient.OciRepos.Get(ctx, name) + if err != nil { + return err } switch outputFormat { @@ -272,7 +246,7 @@ func runRepositoryGet(cmd *cobra.Command, args []string) error { return nil } -func renderRepository(repo *api.OciRepo, tagCount int) error { +func renderRepository(repo *ocirepos.OciRepo, tagCount int) error { tmplBytes, err := repositoryTemplates.ReadFile("templates/repository.get.md.tmpl") if err != nil { return fmt.Errorf("failed to read template: %w", err) @@ -289,9 +263,9 @@ func renderRepository(repo *api.OciRepo, tagCount int) error { } data := struct { - *api.OciRepo + *ocirepos.OciRepo TypeLabel string - ShownTags []api.OciRepoTag + ShownTags []types.OciRepoTag TotalTags int Truncated bool FormatTime func(time.Time) string @@ -336,9 +310,9 @@ func runRepositoryCreate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } return createOciRepoCommon(ctx, mdClient, name, typeFlag, attrs) @@ -346,24 +320,24 @@ func runRepositoryCreate(cmd *cobra.Command, args []string) error { // createOciRepoCommon is shared by `mass repository create` and // `mass bundle create`. It resolves the friendly type name into the -// OciArtifactType enum, calls the API, and prints a success line. -func createOciRepoCommon(ctx context.Context, mdClient *client.Client, name, typeFlag string, attrs map[string]string) error { - enumValue, ok := createTypeEnums[strings.ToLower(typeFlag)] - if !ok { - valid := make([]string, 0, len(createTypeEnums)) - for k := range createTypeEnums { +// ArtifactType enum, calls the SDK, and prints a success line. +func createOciRepoCommon(ctx context.Context, mdClient *massdriver.Client, name, typeFlag string, attrs map[string]string) error { + artifactType, resolveErr := resolveArtifactType(typeFlag) + if resolveErr != nil { + valid := make([]string, 0, len(artifactTypeAliases)) + for k := range artifactTypeAliases { valid = append(valid, k) } return fmt.Errorf("unknown artifact type %q (valid: %s)", typeFlag, strings.Join(valid, ", ")) } - created, createErr := api.CreateOciRepo(ctx, mdClient, api.CreateOciRepoInput{ - Id: name, - ArtifactType: enumValue, + created, err := mdClient.OciRepos.Create(ctx, ocirepos.CreateInput{ + ID: name, + ArtifactType: artifactType, Attributes: cli.AttributesToAnyMap(attrs), }) - if createErr != nil { - return createErr + if err != nil { + return err } fmt.Printf("✅ Repository `%s` created (type: %s)\n", created.Name, artifactTypeLabel(created.ArtifactType)) @@ -381,28 +355,28 @@ func runRepositoryUpdate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - current, getErr := api.GetOciRepo(ctx, mdClient, name) - if getErr != nil { - return getErr + current, err := mdClient.OciRepos.Get(ctx, name) + if err != nil { + return err } var attributes map[string]any if cmd.Flags().Changed("attributes") { attributes = cli.AttributesToAnyMap(attrs) } else { - attributes = cli.StringMapToAnyMap(current.Attributes) + attributes = current.Attributes } - updated, updateErr := api.UpdateOciRepo(ctx, mdClient, name, api.UpdateOciRepoInput{ + updated, err := mdClient.OciRepos.Update(ctx, name, ocirepos.UpdateInput{ Attributes: attributes, }) - if updateErr != nil { - return updateErr + if err != nil { + return err } fmt.Printf("✅ Repository `%s` updated\n", updated.Name) @@ -420,9 +394,9 @@ func runRepositoryDelete(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } if !force { @@ -437,9 +411,9 @@ func runRepositoryDelete(cmd *cobra.Command, args []string) error { } } - deleted, deleteErr := api.DeleteOciRepo(ctx, mdClient, name) - if deleteErr != nil { - return deleteErr + deleted, err := mdClient.OciRepos.Delete(ctx, name) + if err != nil { + return err } fmt.Printf("Repository %s deleted successfully\n", deleted.Name) diff --git a/cmd/resource.go b/cmd/resource.go index 0671869f..bf96a17a 100644 --- a/cmd/resource.go +++ b/cmd/resource.go @@ -6,14 +6,15 @@ import ( "embed" "encoding/json" "fmt" + "os" "text/template" "github.com/charmbracelet/glamour" "github.com/massdriver-cloud/mass/docs/helpdocs" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/cli" "github.com/massdriver-cloud/mass/internal/commands/resource" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" "github.com/spf13/cobra" ) @@ -92,10 +93,24 @@ func NewCmdResource() *cobra.Command { resourceUpdateCmd.Flags().StringP("file", "f", "", "Resource payload file") _ = resourceUpdateCmd.MarkFlagRequired("file") + resourceDeleteCmd := &cobra.Command{ + Use: "delete [resource-id]", + Short: "Delete a resource", + Args: cobra.ExactArgs(1), + RunE: runResourceDelete, + Example: ` # Delete an imported resource + mass resource delete 12345678-1234-1234-1234-123456789012 + + # Skip the confirmation prompt + mass resource delete 12345678-1234-1234-1234-123456789012 --force`, + } + resourceDeleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + resourceCmd.AddCommand(resourceCreateCmd) resourceCmd.AddCommand(resourceGetCmd) resourceCmd.AddCommand(resourceDownloadCmd) resourceCmd.AddCommand(resourceUpdateCmd) + resourceCmd.AddCommand(resourceDeleteCmd) return resourceCmd } @@ -117,18 +132,18 @@ func runResourceCreate(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } + api := resource.NewAPI(mdClient) promptData := resource.CreatePrompt{Name: resourceName, Type: resourceType, File: resourceFile} - promptErr := resource.RunCreatePrompt(ctx, mdClient, &promptData) - if promptErr != nil { - return promptErr + if err := resource.RunCreatePrompt(ctx, api, &promptData); err != nil { + return err } - _, createErr := resource.RunCreate(ctx, mdClient, promptData.Name, promptData.Type, promptData.File) + _, createErr := resource.RunCreate(ctx, api, promptData.Name, promptData.Type, promptData.File) return createErr } @@ -146,16 +161,33 @@ func runResourceUpdate(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - _, updateErr := resource.RunUpdate(ctx, mdClient, resourceID, resourceName, resourceFile) + _, updateErr := resource.RunUpdate(ctx, resource.NewAPI(mdClient), resourceID, resourceName, resourceFile) return updateErr } -//nolint:dupl // runResourceGet and runDefinitionGet share the same output-format pattern; refactoring would add complexity for marginal gain +func runResourceDelete(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + resourceID := 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) + } + + return resource.RunDelete(ctx, resource.NewAPI(mdClient), resourceID, force, os.Stdin) +} + func runResourceGet(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -166,25 +198,25 @@ func runResourceGet(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - resource, getErr := api.GetResource(ctx, mdClient, resourceID) - if getErr != nil { - return fmt.Errorf("error getting resource: %w", getErr) + res, err := mdClient.Resources.Get(ctx, resourceID) + if err != nil { + return fmt.Errorf("error getting resource: %w", err) } switch outputFormat { case "json": - jsonBytes, marshalErr := json.MarshalIndent(resource, "", " ") + jsonBytes, marshalErr := json.MarshalIndent(res, "", " ") if marshalErr != nil { return fmt.Errorf("failed to marshal resource to JSON: %w", marshalErr) } fmt.Println(string(jsonBytes)) case "text": - err = renderResource(resource) + err = renderResource(res) if err != nil { return err } @@ -205,24 +237,24 @@ func runResourceDownload(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - rendered, downloadErr := api.ExportResource(ctx, mdClient, resourceID, format) - if downloadErr != nil { - return fmt.Errorf("error downloading resource: %w", downloadErr) + exported, err := mdClient.Resources.Export(ctx, resourceID, format) + if err != nil { + return fmt.Errorf("error downloading resource: %w", err) } - fmt.Print(rendered) + fmt.Print(exported.Rendered) return nil } -func renderResource(resource *api.Resource) error { +func renderResource(res *types.Resource) error { prettyPayload := "{}" - if resource.Payload != nil { - payloadBytes, err := json.MarshalIndent(resource.Payload, "", " ") + if res.Payload != nil { + payloadBytes, err := json.MarshalIndent(res.Payload, "", " ") if err == nil { prettyPayload = string(payloadBytes) } @@ -238,6 +270,11 @@ func renderResource(resource *api.Resource) error { return fmt.Errorf("failed to parse template: %w", err) } + typeName := "" + if res.ResourceType != nil { + typeName = res.ResourceType.Name + } + data := struct { ID string Name string @@ -248,20 +285,20 @@ func renderResource(resource *api.Resource) error { Formats []string CreatedAt string UpdatedAt string - ResourceType *api.ResourceType - Instance *api.Instance + ResourceType *types.ResourceType + Instance *types.Instance }{ - ID: resource.ID, - Name: resource.Name, - Type: resource.ResourceType.Name, - Field: resource.Field, - Origin: resource.Origin, + ID: res.ID, + Name: res.Name, + Type: typeName, + Field: res.Field, + Origin: res.Origin, Payload: prettyPayload, - Formats: resource.Formats, - CreatedAt: resource.CreatedAt.Format("2006-01-02 15:04:05"), - UpdatedAt: resource.UpdatedAt.Format("2006-01-02 15:04:05"), - ResourceType: resource.ResourceType, - Instance: resource.Instance, + Formats: res.Formats, + CreatedAt: res.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: res.UpdatedAt.Format("2006-01-02 15:04:05"), + ResourceType: res.ResourceType, + Instance: res.Instance, } var buf bytes.Buffer diff --git a/cmd/resource_type.go b/cmd/resource_type.go index 720a32ae..3f038a2e 100644 --- a/cmd/resource_type.go +++ b/cmd/resource_type.go @@ -13,13 +13,11 @@ import ( "github.com/charmbracelet/glamour" "github.com/massdriver-cloud/mass/docs/helpdocs" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/cli" "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/mass/internal/resourcetype" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/spf13/cobra" - - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" ) //go:embed templates/type.get.md.tmpl @@ -75,7 +73,6 @@ func NewCmdType() *cobra.Command { return typeCmd } -//nolint:dupl // runTypeGet and runResourceGet share the same output-format pattern; refactoring would add complexity for marginal gain func runTypeGet(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -86,14 +83,14 @@ func runTypeGet(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - rt, getErr := resourcetype.Get(ctx, mdClient, typeName) - if getErr != nil { - return fmt.Errorf("error getting resource type: %w", getErr) + rt, err := resourcetype.Get(ctx, mdClient, typeName) + if err != nil { + return fmt.Errorf("error getting resource type: %w", err) } switch outputFormat { @@ -121,9 +118,9 @@ func runTypePublish(cmd *cobra.Command, args []string) error { defFile := args[0] cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } artDef, publishErr := resourcetype.Publish(ctx, mdClient, defFile) @@ -140,17 +137,17 @@ func runTypeList(cmd *cobra.Command, args []string) error { ctx := context.Background() cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - resourceTypes, err := api.ListResourceTypes(ctx, mdClient, nil) + resourceTypes, err := resourcetype.List(ctx, mdClient) tbl := cli.NewTable("ID", "Name", "Updated At") - for _, resourceType := range resourceTypes { - tbl.AddRow(resourceType.ID, resourceType.Name, resourceType.UpdatedAt) + for _, rt := range resourceTypes { + tbl.AddRow(rt.ID, rt.Name, rt.UpdatedAt) } tbl.Print() @@ -158,7 +155,7 @@ func runTypeList(cmd *cobra.Command, args []string) error { return err } -func renderType(restype *api.ResourceType) error { +func renderType(restype *resourcetype.ResourceType) error { schemaJSON, err := json.MarshalIndent(restype.Schema, "", " ") if err != nil { return err @@ -213,15 +210,15 @@ func runTypeDelete(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } // Get resource type details for confirmation - rt, getErr := resourcetype.Get(ctx, mdClient, typeName) - if getErr != nil { - return fmt.Errorf("error getting resource type: %w", getErr) + 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 @@ -238,11 +235,11 @@ func runTypeDelete(cmd *cobra.Command, args []string) error { } } - deleteErr := resourcetype.Delete(ctx, mdClient, typeName, force) + 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(typeName)) + fmt.Printf("Resource type %s deleted successfully!\n", prettylogs.Underline(deleted.Name)) return nil } diff --git a/cmd/schema.go b/cmd/schema.go index b71e966c..e82eb43b 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -10,7 +10,7 @@ import ( "github.com/massdriver-cloud/mass/internal/jsonschema" "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/mass/internal/resourcetype" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/spf13/cobra" ) @@ -64,14 +64,14 @@ func runSchemaDereference(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to decode JSON schema: %w", err) } - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } derefOpts := resourcetype.DereferenceOptions{ - Client: mdClient, - Cwd: basePath, + Resolver: resourcetype.NewMassdriverResolver(mdClient), + Cwd: basePath, } dereferencedSchema, derefErr := resourcetype.DereferenceSchema(rawSchema, derefOpts) if derefErr != nil { diff --git a/cmd/templates/environment.get.md.tmpl b/cmd/templates/environment.get.md.tmpl index d641ca1d..351d66b8 100644 --- a/cmd/templates/environment.get.md.tmpl +++ b/cmd/templates/environment.get.md.tmpl @@ -14,15 +14,11 @@ No attributes {{- end}} ## Instances -{{- with .Blueprint}} {{- if .Instances}} | Name | Bundle | Status | | ---- | ------ | ------ | {{- range .Instances}} -| **{{.Name | mdEscape}}** | {{.Bundle.Name | mdEscape}} | {{.Status | mdEscape}} | -{{- end}} -{{- else}} -No instances +| **{{.Name | mdEscape}}** | {{with .Bundle}}{{.Name | mdEscape}}{{end}} | {{.Status | mdEscape}} | {{- end}} {{- else}} No instances diff --git a/cmd/version.go b/cmd/version.go index ce8a8630..8a3363bf 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -4,10 +4,9 @@ import ( "context" "fmt" - "github.com/massdriver-cloud/mass/internal/api" "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/mass/internal/version" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/spf13/cobra" ) @@ -41,12 +40,12 @@ func runVersion(cmd *cobra.Command, args []string) { // Best-effort: if we can authenticate, show the Massdriver server version too. ctx := context.Background() - mdClient, err := client.New() + mdClient, err := massdriver.NewClient() if err != nil { return } - if server, err := api.GetServer(ctx, mdClient); err == nil && server != nil && server.Version != "" { + if server, err := mdClient.Server.Get(ctx); err == nil && server != nil && server.Version != "" { fmt.Printf("🌐 Server version: %v\n", prettylogs.Green(server.Version)) } } diff --git a/cmd/whoami.go b/cmd/whoami.go index 9d9554a6..10c4cab4 100644 --- a/cmd/whoami.go +++ b/cmd/whoami.go @@ -6,8 +6,8 @@ import ( "fmt" "strings" - "github.com/massdriver-cloud/mass/internal/api" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/viewer" "github.com/spf13/cobra" ) @@ -32,25 +32,25 @@ func runWhoami(cmd *cobra.Command, args []string) error { return err } - mdClient, mdClientErr := client.New() - if mdClientErr != nil { - return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) } - viewer, viewerErr := api.GetViewer(ctx, mdClient) - if viewerErr != nil { - return viewerErr + v, err := mdClient.Viewer.Get(ctx) + if err != nil { + return err } switch outputFormat { case "json": - bytes, marshalErr := json.MarshalIndent(viewer, "", " ") + bytes, marshalErr := json.MarshalIndent(v, "", " ") if marshalErr != nil { return fmt.Errorf("failed to marshal viewer to JSON: %w", marshalErr) } fmt.Println(string(bytes)) case "text": - printViewer(viewer) + printViewer(v) default: return fmt.Errorf("unsupported output format: %s", outputFormat) } @@ -58,16 +58,16 @@ func runWhoami(cmd *cobra.Command, args []string) error { return nil } -func printViewer(v *api.Viewer) { +func printViewer(v *viewer.Viewer) { switch v.Kind { - case api.ViewerKindAccount: + case viewer.KindAccount: fmt.Println("👤 User") fmt.Printf(" ID: %s\n", v.ID) fmt.Printf(" Email: %s\n", v.Email) if name := strings.TrimSpace(v.FirstName + " " + v.LastName); name != "" { fmt.Printf(" Name: %s\n", name) } - case api.ViewerKindServiceAccount: + case viewer.KindServiceAccount: fmt.Println("🤖 Service account") fmt.Printf(" ID: %s\n", v.ID) fmt.Printf(" Name: %s\n", v.Name) diff --git a/docs/generated/mass_instance_version.md b/docs/generated/mass_instance_version.md index 4fec6667..63cef664 100644 --- a/docs/generated/mass_instance_version.md +++ b/docs/generated/mass_instance_version.md @@ -12,7 +12,7 @@ Set instance version # Set instance version -Set the version or release channel for an instance in Massdriver. +Set the version for an instance in Massdriver. ## Examples @@ -22,19 +22,8 @@ Set the version for an instance using the `slug@version` format: mass instance version api-prod-db@latest ``` -Set the version with a specific release channel: - -```shell -mass instance version api-prod-db@latest --release-channel development -``` - The `slug` can be found in the instance info panel. The instance slug is a combination of the `--`. -## Release Channels - -- `stable` (default): Instance receives only stable releases -- `development`: Instance receives both stable and development releases - ## Version Format The version can be: @@ -50,14 +39,13 @@ mass instance version @ [flags] ### Examples ``` -mass instance version api-prod-db@latest --release-channel development +mass instance version api-prod-db@latest ``` ### Options ``` - -h, --help help for version - --release-channel string Release strategy (stable or development) (default "stable") + -h, --help help for version ``` ### SEE ALSO diff --git a/docs/generated/mass_resource.md b/docs/generated/mass_resource.md index e3f4a1d3..633d89f6 100644 --- a/docs/generated/mass_resource.md +++ b/docs/generated/mass_resource.md @@ -25,6 +25,7 @@ Resources represent infrastructure outputs and connections in Massdriver. They c * [mass](/cli/commands/mass) - Massdriver Cloud CLI * [mass resource create](/cli/commands/mass_resource_create) - Create a resource +* [mass resource delete](/cli/commands/mass_resource_delete) - Delete a resource * [mass resource download](/cli/commands/mass_resource_download) - Download an resource in the specified format * [mass resource get](/cli/commands/mass_resource_get) - Get an resource from Massdriver * [mass resource update](/cli/commands/mass_resource_update) - Update an imported resource diff --git a/docs/generated/mass_resource_delete.md b/docs/generated/mass_resource_delete.md new file mode 100644 index 00000000..b32600d6 --- /dev/null +++ b/docs/generated/mass_resource_delete.md @@ -0,0 +1,34 @@ +--- +id: mass_resource_delete.md +slug: /cli/commands/mass_resource_delete +title: Mass Resource Delete +sidebar_label: Mass Resource Delete +--- +## mass resource delete + +Delete a resource + +``` +mass resource delete [resource-id] [flags] +``` + +### Examples + +``` + # Delete an imported resource + mass resource delete 12345678-1234-1234-1234-123456789012 + + # Skip the confirmation prompt + mass resource delete 12345678-1234-1234-1234-123456789012 --force +``` + +### Options + +``` + -f, --force Skip confirmation prompt + -h, --help help for delete +``` + +### SEE ALSO + +* [mass resource](/cli/commands/mass_resource) - Manage resources diff --git a/docs/helpdocs/instance/version.md b/docs/helpdocs/instance/version.md index 0c211b76..200901f1 100644 --- a/docs/helpdocs/instance/version.md +++ b/docs/helpdocs/instance/version.md @@ -1,6 +1,6 @@ # Set instance version -Set the version or release channel for an instance in Massdriver. +Set the version for an instance in Massdriver. ## Examples @@ -10,19 +10,8 @@ Set the version for an instance using the `slug@version` format: mass instance version api-prod-db@latest ``` -Set the version with a specific release channel: - -```shell -mass instance version api-prod-db@latest --release-channel development -``` - The `slug` can be found in the instance info panel. The instance slug is a combination of the `--`. -## Release Channels - -- `stable` (default): Instance receives only stable releases -- `development`: Instance receives both stable and development releases - ## Version Format The version can be: diff --git a/go.mod b/go.mod index e93f7c8d..ed8d9a78 100644 --- a/go.mod +++ b/go.mod @@ -7,19 +7,16 @@ toolchain go1.24.12 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/BurntSushi/toml v1.4.0 - github.com/Khan/genqlient v0.8.0 + github.com/Khan/genqlient v0.8.1 github.com/charmbracelet/glamour v0.8.0 github.com/charmbracelet/lipgloss v1.0.0 github.com/fatih/color v1.17.0 - github.com/go-resty/resty/v2 v2.16.5 - github.com/gorilla/websocket v1.5.3 github.com/hashicorp/hcl/v2 v2.23.0 github.com/itchyny/gojq v0.12.16 github.com/manifoldco/promptui v0.9.0 github.com/massdriver-cloud/airlock v0.0.9 - github.com/massdriver-cloud/massdriver-sdk-go v0.1.1 + github.com/massdriver-cloud/massdriver-sdk-go v0.2.0 github.com/mattn/go-runewidth v0.0.16 - github.com/mitchellh/mapstructure v1.5.0 github.com/opencontainers/image-spec v1.1.1 github.com/osteele/liquid v1.7.0 github.com/rodaine/table v1.3.0 @@ -41,8 +38,8 @@ require ( github.com/agext/levenshtein v1.2.3 // indirect github.com/agnivade/levenshtein v1.1.1 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect - github.com/alexflint/go-arg v1.4.2 // indirect - github.com/alexflint/go-scalar v1.0.0 // indirect + github.com/alexflint/go-arg v1.5.1 // indirect + github.com/alexflint/go-scalar v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -56,9 +53,12 @@ require ( github.com/creack/pty v1.1.18 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/go-resty/resty/v2 v2.16.5 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect @@ -79,9 +79,7 @@ require ( github.com/osteele/tuesday v1.0.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/rs/zerolog v1.33.0 // indirect - github.com/sergi/go-diff v1.4.0 // indirect github.com/sosedoff/ansible-vault-go v0.2.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/objx v0.5.2 // indirect @@ -95,6 +93,5 @@ require ( golang.org/x/sys v0.33.0 // indirect golang.org/x/term v0.32.0 // indirect golang.org/x/tools v0.33.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 1e519b7c..9ea7226e 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0 github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Checkmarx/kics/v2 v2.1.3 h1:X+53sjAq9suwzCmZwdGqpBkIgcL1vp5KYqNEbA0woAs= github.com/Checkmarx/kics/v2 v2.1.3/go.mod h1:66f8m0G8klx4G15sQhuOserSxcni6QpjGiMmXLaIFos= -github.com/Khan/genqlient v0.8.0 h1:Hd1a+E1CQHYbMEKakIkvBH3zW0PWEeiX6Hp1i2kP2WE= -github.com/Khan/genqlient v0.8.0/go.mod h1:hn70SpYjWteRGvxTwo0kfaqg4wxvndECGkfa1fdDdYI= +github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs= +github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= @@ -18,10 +18,10 @@ github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46 github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/alexflint/go-arg v1.4.2 h1:lDWZAXxpAnZUq4qwb86p/3rIJJ2Li81EoMbTMujhVa0= -github.com/alexflint/go-arg v1.4.2/go.mod h1:9iRbDxne7LcR/GSvEr7ma++GLpdIU1zrghf2y2768kM= -github.com/alexflint/go-scalar v1.0.0 h1:NGupf1XV/Xb04wXskDFzS0KWOLH632W/EO4fAFi+A70= -github.com/alexflint/go-scalar v1.0.0/go.mod h1:GpHzbCOZXEKMEcygYQ5n/aa4Aq84zbxjy3MxYW0gjYw= +github.com/alexflint/go-arg v1.5.1 h1:nBuWUCpuRy0snAG+uIJ6N0UvYxpxA0/ghA/AaHxlT8Y= +github.com/alexflint/go-arg v1.5.1/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8= +github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= +github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -81,6 +81,8 @@ github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptd github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -111,12 +113,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -127,8 +125,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.9 h1:t+jTY6nZEiPZNTKx0wEgQTPztIxL4u0RFvVWXn2/RMc= github.com/massdriver-cloud/airlock v0.0.9/go.mod h1:igJm33JvINiUtbyEspUeKUWyWewG+jYyxO1UDHqLp9Q= -github.com/massdriver-cloud/massdriver-sdk-go v0.1.1 h1:9Y8/DV2rEFLmumPzX6WpSy2TRX93eiu7V+qEbQJpAFY= -github.com/massdriver-cloud/massdriver-sdk-go v0.1.1/go.mod h1:Cb0WmZVmpG3B+ZoIxO7fCrfA4+J7giNXHDjoMrjD1S0= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.0 h1:NNRe6ZB93fnlDRb2j/619Zw9PapBDYUazcMitMtnGjA= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.0/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= github.com/massdriver-cloud/terraform-config-inspect v0.0.1 h1:eLtKFRaklHIxcPvUtZmNacl28n4QIHr29pJzw/u/FKU= github.com/massdriver-cloud/terraform-config-inspect v0.0.1/go.mod h1:3AbDpWxIRMdMAg7FDmTJuVBhCGNwdm49cBIOmUHjqRg= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -149,8 +147,6 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= @@ -197,7 +193,6 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -269,10 +264,8 @@ golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/absinthe/socket.go b/internal/api/absinthe/socket.go deleted file mode 100644 index e38fec0b..00000000 --- a/internal/api/absinthe/socket.go +++ /dev/null @@ -1,349 +0,0 @@ -// Package absinthe implements a thin client for GraphQL subscriptions over -// Phoenix Channels (v2 framing) as exposed by Elixir/Absinthe servers. -// -// It is intentionally generic — callers supply a GraphQL operation string and -// variables, and receive the raw `data` payload of each subscription:data frame. -// No domain types from the surrounding application leak into this package, so -// it can be lifted into the SDK alongside the gql client without changes. -package absinthe - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/url" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/gorilla/websocket" -) - -const ( - controlTopic = "__absinthe__:control" - heartbeatTopic = "phoenix" - heartbeatEvent = "heartbeat" - heartbeatInterval = 30 * time.Second - replyTimeout = 30 * time.Second - socketPath = "/api/socket/websocket" - phoenixVsn = "2.0.0" -) - -// Socket is an open Phoenix Channels connection that has joined Absinthe's -// `__absinthe__:control` topic and can multiplex GraphQL subscriptions over a -// single WebSocket. -type Socket struct { - conn *websocket.Conn - nextRef atomic.Uint64 - - joinRef string - - writeMu sync.Mutex // serializes writes; gorilla requires single-writer - - mu sync.Mutex - pending map[string]chan reply // ref → reply slot - subscribers map[string]chan json.RawMessage // subscriptionId (== Phoenix topic) → data - closed bool - closeErr error - - closeConnOne sync.Once // gates conn.Close so user-Close is idempotent - connCloseErr error // captured once for the caller - done chan struct{} // closed when the read loop exits -} - -// reply carries a phx_reply payload back to the caller awaiting it. -type reply struct { - status string - response json.RawMessage -} - -// Dial opens a WebSocket to baseURL's Phoenix endpoint, joins -// `__absinthe__:control`, and starts the read/heartbeat loops. -// -// baseURL is the HTTPS API base (e.g. "https://api.example.com"). It's converted -// to wss://example.com/api/socket/websocket?token=&vsn=2.0.0. The token is -// sent as a query parameter because browsers can't set headers on WebSocket -// upgrades — Phoenix UserSockets typically accept it from `connect/3`. -func Dial(ctx context.Context, baseURL, token string) (*Socket, error) { - wsURL, err := buildWSURL(baseURL, token) - if err != nil { - return nil, err - } - - conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil) - if err != nil { - return nil, fmt.Errorf("absinthe dial: %w", err) - } - - s := &Socket{ - conn: conn, - pending: make(map[string]chan reply), - subscribers: make(map[string]chan json.RawMessage), - done: make(chan struct{}), - } - go s.readLoop() - - if joinErr := s.join(ctx); joinErr != nil { - _ = s.Close() - return nil, joinErr - } - go s.heartbeatLoop() - return s, nil -} - -// Close terminates the WebSocket and aborts any in-flight subscriptions. -// Safe to call multiple times and from multiple goroutines. -func (s *Socket) Close() error { - s.shutdown(nil) - s.closeConnOne.Do(func() { - s.connCloseErr = s.conn.Close() - }) - return s.connCloseErr -} - -// Err returns the error that caused the socket to close, if any. Returns nil -// while the socket is healthy. -func (s *Socket) Err() error { - s.mu.Lock() - defer s.mu.Unlock() - return s.closeErr -} - -func (s *Socket) join(ctx context.Context) error { - s.joinRef = s.refID() - resp, err := s.push(ctx, &s.joinRef, s.joinRef, controlTopic, "phx_join", json.RawMessage("{}")) - if err != nil { - return fmt.Errorf("absinthe join: %w", err) - } - if resp.status != "ok" { - return fmt.Errorf("absinthe join: status=%s response=%s", resp.status, string(resp.response)) - } - return nil -} - -// push writes a frame and (if ref is non-nil) waits for the matching phx_reply. -// joinRef is the channel's join_ref; for control-topic pushes it's the join we -// did at startup, for heartbeats it's nil. -func (s *Socket) push(ctx context.Context, joinRef *string, ref, topic, event string, payload json.RawMessage) (reply, error) { - ch := make(chan reply, 1) - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return reply{}, fmt.Errorf("absinthe socket closed: %w", s.closeErr) - } - s.pending[ref] = ch - s.mu.Unlock() - - defer func() { - s.mu.Lock() - delete(s.pending, ref) - s.mu.Unlock() - }() - - if err := s.writeFrame(joinRef, &ref, topic, event, payload); err != nil { - return reply{}, err - } - - select { - case r := <-ch: - return r, nil - case <-ctx.Done(): - return reply{}, ctx.Err() - case <-s.done: - return reply{}, fmt.Errorf("absinthe socket closed: %w", s.Err()) - case <-time.After(replyTimeout): - return reply{}, fmt.Errorf("absinthe push %s: timeout waiting for reply", event) - } -} - -// writeFrame sends one Phoenix v2 frame. payload may be nil to indicate {}. -func (s *Socket) writeFrame(joinRef, ref *string, topic, event string, payload json.RawMessage) error { - if len(payload) == 0 { - payload = json.RawMessage("{}") - } - frame := []any{joinRef, ref, topic, event, payload} - body, err := json.Marshal(frame) - if err != nil { - return err - } - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.conn.WriteMessage(websocket.TextMessage, body) -} - -func (s *Socket) refID() string { - return strconv.FormatUint(s.nextRef.Add(1), 10) -} - -func (s *Socket) readLoop() { - defer close(s.done) - for { - _, body, err := s.conn.ReadMessage() - if err != nil { - s.shutdown(err) - return - } - s.dispatch(body) - } -} - -func (s *Socket) dispatch(body []byte) { - var arr []json.RawMessage - if err := json.Unmarshal(body, &arr); err != nil || len(arr) != 5 { - // Malformed — ignore. Phoenix doesn't send anything else over WS. - return - } - var ( - joinRef *string - ref *string - topic string - event string - ) - _ = json.Unmarshal(arr[0], &joinRef) - _ = json.Unmarshal(arr[1], &ref) - if err := json.Unmarshal(arr[2], &topic); err != nil { - return - } - if err := json.Unmarshal(arr[3], &event); err != nil { - return - } - payload := arr[4] - - switch event { - case "phx_reply": - if ref == nil { - return - } - var p struct { - Status string `json:"status"` - Response json.RawMessage `json:"response"` - } - _ = json.Unmarshal(payload, &p) - s.mu.Lock() - ch, ok := s.pending[*ref] - s.mu.Unlock() - if ok { - ch <- reply{status: p.Status, response: p.Response} - } - case "subscription:data": - s.mu.Lock() - ch, ok := s.subscribers[topic] - s.mu.Unlock() - if !ok { - return - } - // Payload is {"result": {"data": ..., "errors": ...}, "subscriptionId": "..."}. - // We pass the whole payload through; the domain caller will pick what it wants. - select { - case ch <- payload: - default: - // drop on slow consumer; subscriptions:data is fire-and-forget - } - case "phx_error", "phx_close": - s.shutdown(fmt.Errorf("absinthe socket received %s on topic %s: %s", event, topic, string(payload))) - } -} - -func (s *Socket) heartbeatLoop() { - t := time.NewTicker(heartbeatInterval) - defer t.Stop() - for { - select { - case <-s.done: - return - case <-t.C: - ref := s.refID() - // Heartbeats use a nil join_ref. Don't wait for the reply. - _ = s.writeFrame(nil, &ref, heartbeatTopic, heartbeatEvent, nil) - } - } -} - -func (s *Socket) shutdown(cause error) { - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return - } - s.closed = true - if cause != nil && !isNormalClose(cause) { - s.closeErr = cause - } - pending := s.pending - s.pending = nil - subs := s.subscribers - s.subscribers = nil - s.mu.Unlock() - - for _, ch := range pending { - close(ch) - } - for _, ch := range subs { - close(ch) - } -} - -// registerSubscription wires up a topic→channel route for incoming -// subscription:data frames. Returns the channel. -func (s *Socket) registerSubscription(topic string) chan json.RawMessage { - ch := make(chan json.RawMessage, 64) - s.mu.Lock() - if s.closed { - s.mu.Unlock() - close(ch) - return ch - } - s.subscribers[topic] = ch - s.mu.Unlock() - return ch -} - -func (s *Socket) unregisterSubscription(topic string) { - s.mu.Lock() - ch, ok := s.subscribers[topic] - if ok { - delete(s.subscribers, topic) - } - s.mu.Unlock() - if ok { - close(ch) - } -} - -func buildWSURL(baseURL, token string) (string, error) { - u, err := url.Parse(baseURL) - if err != nil { - return "", fmt.Errorf("parse base URL: %w", err) - } - switch strings.ToLower(u.Scheme) { - case "https": - u.Scheme = "wss" - case "http": - u.Scheme = "ws" - default: - return "", fmt.Errorf("unsupported scheme %q", u.Scheme) - } - u.Path = strings.TrimRight(u.Path, "/") + socketPath - q := u.Query() - q.Set("token", token) - q.Set("vsn", phoenixVsn) - u.RawQuery = q.Encode() - return u.String(), nil -} - -func isNormalClose(err error) bool { - if errors.Is(err, websocket.ErrCloseSent) { - return true - } - var ce *websocket.CloseError - if errors.As(err, &ce) { - switch ce.Code { - case websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived: - return true - } - } - return false -} diff --git a/internal/api/absinthe/subscription.go b/internal/api/absinthe/subscription.go deleted file mode 100644 index c1fb3ffe..00000000 --- a/internal/api/absinthe/subscription.go +++ /dev/null @@ -1,123 +0,0 @@ -package absinthe - -import ( - "context" - "encoding/json" - "fmt" - "sync" -) - -// Subscription is an open Absinthe subscription. Iterate Data until it closes, -// then check Err() for any failure cause (nil means a clean termination). -type Subscription struct { - // ID is the subscriptionId returned by Absinthe (also the Phoenix topic on - // which subsequent subscription:data frames arrive). - ID string - - // Data yields the raw `data` payload of each subscription:data frame (the - // inner contents of `result.data`, with no envelope). Closed when the - // socket dies, the subscription is closed, or the server completes. - Data <-chan json.RawMessage - - socket *Socket - closeOne sync.Once -} - -// Subscribe pushes a GraphQL subscription document on the Absinthe control -// channel and registers the resulting subscriptionId for routing. -func (s *Socket) Subscribe(ctx context.Context, query string, variables map[string]any) (*Subscription, error) { - docPayload := struct { - Query string `json:"query"` - Variables map[string]any `json:"variables,omitempty"` - }{Query: query, Variables: variables} - body, err := json.Marshal(docPayload) - if err != nil { - return nil, fmt.Errorf("absinthe subscribe: marshal doc: %w", err) - } - - ref := s.refID() - resp, err := s.push(ctx, &s.joinRef, ref, controlTopic, "doc", body) - if err != nil { - return nil, err - } - if resp.status != "ok" { - return nil, fmt.Errorf("absinthe subscribe: status=%s response=%s", resp.status, string(resp.response)) - } - - var subResp struct { - SubscriptionID string `json:"subscriptionId"` - } - if err := json.Unmarshal(resp.response, &subResp); err != nil { - return nil, fmt.Errorf("absinthe subscribe: decode subscriptionId: %w", err) - } - if subResp.SubscriptionID == "" { - return nil, fmt.Errorf("absinthe subscribe: server returned no subscriptionId (response=%s)", string(resp.response)) - } - - raw := s.registerSubscription(subResp.SubscriptionID) - out := make(chan json.RawMessage, cap(raw)) - - go func() { - defer close(out) - for payload := range raw { - data, ok := extractData(payload) - if !ok { - continue - } - select { - case out <- data: - case <-s.done: - return - } - } - }() - - return &Subscription{ - ID: subResp.SubscriptionID, - Data: out, - socket: s, - }, nil -} - -// Close unsubscribes on the server side and stops routing data to this -// Subscription's channel. Safe to call multiple times and from multiple -// goroutines. -func (sub *Subscription) Close() error { - sub.closeOne.Do(func() { - // Best-effort unsubscribe; ignore errors (the server may already have - // dropped the sub or the socket may be closing). - body, _ := json.Marshal(struct { - SubscriptionID string `json:"subscriptionId"` - }{SubscriptionID: sub.ID}) - ctx, cancel := context.WithTimeout(context.Background(), replyTimeout) - defer cancel() - ref := sub.socket.refID() - _, _ = sub.socket.push(ctx, &sub.socket.joinRef, ref, controlTopic, "unsubscribe", body) - - sub.socket.unregisterSubscription(sub.ID) - }) - return nil -} - -// Err returns the error that caused the underlying socket to close, if any. -func (sub *Subscription) Err() error { - return sub.socket.Err() -} - -// extractData unwraps an Absinthe subscription:data payload to its inner data -// object. Returns false if the payload is malformed or has only errors. -func extractData(payload json.RawMessage) (json.RawMessage, bool) { - var env struct { - Result struct { - Data json.RawMessage `json:"data"` - Errors json.RawMessage `json:"errors"` - } `json:"result"` - } - if err := json.Unmarshal(payload, &env); err != nil { - return nil, false - } - if len(env.Result.Data) == 0 || string(env.Result.Data) == "null" { - return nil, false - } - return env.Result.Data, true -} diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 00000000..4e95c7a0 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,71 @@ +// 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/blueprint.go b/internal/api/blueprint.go deleted file mode 100644 index 845243f4..00000000 --- a/internal/api/blueprint.go +++ /dev/null @@ -1,7 +0,0 @@ -// Package api provides a client for the Massdriver v2 GraphQL API. -package api - -// Blueprint represents the modeled infrastructure for an environment. -type Blueprint struct { - Instances []Instance `json:"instances,omitempty"` -} diff --git a/internal/api/bundle.go b/internal/api/bundle.go deleted file mode 100644 index 15cfc214..00000000 --- a/internal/api/bundle.go +++ /dev/null @@ -1,76 +0,0 @@ -package api - -import ( - "context" - "fmt" - "time" - - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -// Bundle represents a Massdriver bundle (IaC module) and its metadata. -type Bundle struct { - ID string `json:"id" mapstructure:"id"` - Name string `json:"name" mapstructure:"name"` - Version string `json:"version" mapstructure:"version"` - Description string `json:"description,omitempty" mapstructure:"description"` - Icon string `json:"icon,omitempty" mapstructure:"icon"` - SourceURL string `json:"sourceUrl,omitempty" mapstructure:"sourceUrl"` - Repo string `json:"repo,omitempty" mapstructure:"repo"` - CreatedAt time.Time `json:"createdAt,omitzero" mapstructure:"createdAt"` - UpdatedAt time.Time `json:"updatedAt,omitzero" mapstructure:"updatedAt"` - Dependencies []BundleSlot `json:"dependencies,omitempty" mapstructure:"dependencies"` - Resources []BundleSlot `json:"resources,omitempty" mapstructure:"resources"` -} - -// BundleSlot describes one of a bundle's input dependencies or output -// resources. Dependencies are slots the user must wire up; resources are -// outputs the bundle produces on a successful deployment. -type BundleSlot struct { - Name string `json:"name" mapstructure:"name"` - Required bool `json:"required" mapstructure:"required"` - ResourceType *BundleResourceRef `json:"resourceType,omitempty" mapstructure:"resourceType"` -} - -// BundleResourceRef points at a resource type the bundle declares. May be -// nil when the original resource type has been removed from the catalog. -type BundleResourceRef struct { - ID string `json:"id" mapstructure:"id"` - Name string `json:"name" mapstructure:"name"` -} - -// GetBundle retrieves a bundle by its identifier (e.g., "aws-aurora-postgres@1.2.3" or "aws-aurora-postgres@latest"). -func GetBundle(ctx context.Context, mdClient *client.Client, id string) (*Bundle, error) { - response, err := getBundle(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id) - if err != nil { - return nil, fmt.Errorf("failed to get bundle %s: %w", id, err) - } - return toBundle(response.Bundle) -} - -// ListBundles returns bundles, optionally filtered and sorted. -func ListBundles(ctx context.Context, mdClient *client.Client, filter *BundlesFilter, sort *BundlesSort) ([]Bundle, error) { - response, err := listBundles(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, filter, sort, nil) - if err != nil { - return nil, fmt.Errorf("failed to list bundles: %w", err) - } - - bundles := make([]Bundle, 0, len(response.Bundles.Items)) - for _, resp := range response.Bundles.Items { - b, bErr := toBundle(resp) - if bErr != nil { - return nil, fmt.Errorf("failed to convert bundle: %w", bErr) - } - bundles = append(bundles, *b) - } - - return bundles, nil -} - -func toBundle(v any) (*Bundle, error) { - b := Bundle{} - if err := decode(v, &b); err != nil { - return nil, fmt.Errorf("failed to decode bundle: %w", err) - } - return &b, nil -} diff --git a/internal/api/bundle_test.go b/internal/api/bundle_test.go deleted file mode 100644 index 6aeffa75..00000000 --- a/internal/api/bundle_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package api_test - -import ( - "testing" - - api "github.com/massdriver-cloud/mass/internal/api" - "github.com/massdriver-cloud/mass/internal/gqlmock" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -func TestGetBundle(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "bundle": map[string]any{ - "id": "aws-aurora-postgres@1.2.3", - "name": "aws-aurora-postgres", - "version": "1.2.3", - "description": "Aurora PostgreSQL cluster", - "icon": "https://example.com/icon.png", - "sourceUrl": "https://github.com/example/repo", - "repo": "aws-aurora-postgres", - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - bundle, err := api.GetBundle(t.Context(), &mdClient, "aws-aurora-postgres@1.2.3") - if err != nil { - t.Fatal(err) - } - - if bundle.ID != "aws-aurora-postgres@1.2.3" { - t.Errorf("got ID %s, wanted aws-aurora-postgres@1.2.3", bundle.ID) - } - if bundle.Name != "aws-aurora-postgres" { - t.Errorf("got name %s, wanted aws-aurora-postgres", bundle.Name) - } - if bundle.Version != "1.2.3" { - t.Errorf("got version %s, wanted 1.2.3", bundle.Version) - } - if bundle.Repo != "aws-aurora-postgres" { - t.Errorf("got repo %s, wanted aws-aurora-postgres", bundle.Repo) - } -} - -func TestListBundles(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "bundles": map[string]any{ - "cursor": map[string]any{}, - "items": []map[string]any{ - { - "id": "aws-aurora-postgres@1.2.3", - "name": "aws-aurora-postgres", - "version": "1.2.3", - "repo": "aws-aurora-postgres", - }, - { - "id": "aws-s3@2.0.0", - "name": "aws-s3", - "version": "2.0.0", - "repo": "aws-s3", - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - bundles, err := api.ListBundles(t.Context(), &mdClient, nil, nil) - if err != nil { - t.Fatal(err) - } - - if len(bundles) != 2 { - t.Errorf("got %d bundles, wanted 2", len(bundles)) - } -} - -func TestListBundlesWithFilter(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "bundles": map[string]any{ - "cursor": map[string]any{}, - "items": []map[string]any{ - { - "id": "aws-aurora-postgres@1.2.3", - "name": "aws-aurora-postgres", - "version": "1.2.3", - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - filter := api.BundlesFilter{ - OciRepo: &api.OciRepoNameFilter{Eq: "aws-aurora-postgres"}, - } - bundles, err := api.ListBundles(t.Context(), &mdClient, &filter, nil) - if err != nil { - t.Fatal(err) - } - - if len(bundles) != 1 { - t.Errorf("got %d bundles, wanted 1", len(bundles)) - } -} diff --git a/internal/api/component.go b/internal/api/component.go deleted file mode 100644 index b59a3b95..00000000 --- a/internal/api/component.go +++ /dev/null @@ -1,185 +0,0 @@ -package api - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -// Component represents a slot in a project's blueprint backed by a bundle. -type Component struct { - ID string `json:"id" mapstructure:"id"` - Name string `json:"name" mapstructure:"name"` - Description string `json:"description,omitempty" mapstructure:"description"` - Attributes map[string]string `json:"attributes,omitempty" mapstructure:"attributes"` - OciRepo *OciRepo `json:"ociRepo,omitempty" mapstructure:"ociRepo,omitempty"` - CreatedAt time.Time `json:"createdAt,omitzero" mapstructure:"createdAt"` - UpdatedAt time.Time `json:"updatedAt,omitzero" mapstructure:"updatedAt"` -} - -// Link represents a design-time wire between two components in a blueprint. -type Link struct { - ID string `json:"id" mapstructure:"id"` - FromField string `json:"fromField" mapstructure:"fromField"` - ToField string `json:"toField" mapstructure:"toField"` - FromComponent *Component `json:"fromComponent,omitempty" mapstructure:"fromComponent,omitempty"` - ToComponent *Component `json:"toComponent,omitempty" mapstructure:"toComponent,omitempty"` - CreatedAt time.Time `json:"createdAt,omitzero" mapstructure:"createdAt"` - UpdatedAt time.Time `json:"updatedAt,omitzero" mapstructure:"updatedAt"` -} - -// ListLinks returns every link in a project's blueprint, optionally filtered, following pagination. -func ListLinks(ctx context.Context, mdClient *client.Client, projectID string, filter *LinksFilter) ([]Link, error) { - var links []Link - var cursor *Cursor - - for { - response, err := listLinks(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, projectID, filter, cursor) - if err != nil { - return nil, fmt.Errorf("failed to list links for project %s: %w", projectID, err) - } - - for _, item := range response.Project.Blueprint.Links.Items { - l, decodeErr := toLink(item) - if decodeErr != nil { - return nil, fmt.Errorf("failed to convert link: %w", decodeErr) - } - links = append(links, *l) - } - - next := response.Project.Blueprint.Links.Cursor.Next - if next == "" { - break - } - cursor = &Cursor{Next: next} - } - - return links, nil -} - -// AddComponent adds a component to a project's blueprint. -func AddComponent(ctx context.Context, mdClient *client.Client, projectID, ociRepoName string, input AddComponentInput) (*Component, error) { - response, err := addComponent(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, projectID, ociRepoName, input) - if err != nil { - return nil, err - } - if !response.AddComponent.Successful { - messages := make([]string, 0, len(response.AddComponent.Messages)) - for _, m := range response.AddComponent.Messages { - messages = append(messages, m.Message) - } - return nil, mutationError("unable to add component", messages) - } - return toComponent(response.AddComponent.Result) -} - -// GetComponent retrieves a component by ID. -func GetComponent(ctx context.Context, mdClient *client.Client, componentID string) (*Component, error) { - response, err := getComponent(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, componentID) - if err != nil { - return nil, fmt.Errorf("failed to get component %s: %w", componentID, err) - } - if response.Component.Id == "" { - return nil, fmt.Errorf("component %s not found", componentID) - } - return toComponent(response.Component) -} - -// UpdateComponent updates a component's name, description, and attributes. -// The component ID and underlying bundle cannot be changed. -func UpdateComponent(ctx context.Context, mdClient *client.Client, componentID string, input UpdateComponentInput) (*Component, error) { - response, err := updateComponent(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, componentID, input) - if err != nil { - return nil, err - } - if !response.UpdateComponent.Successful { - messages := make([]string, 0, len(response.UpdateComponent.Messages)) - for _, m := range response.UpdateComponent.Messages { - messages = append(messages, m.Message) - } - return nil, mutationError("unable to update component", messages) - } - return toComponent(response.UpdateComponent.Result) -} - -// RemoveComponent removes a component from a project's blueprint. -func RemoveComponent(ctx context.Context, mdClient *client.Client, componentID string) (*Component, error) { - response, err := removeComponent(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, componentID) - if err != nil { - return nil, err - } - if !response.RemoveComponent.Successful { - messages := make([]string, 0, len(response.RemoveComponent.Messages)) - for _, m := range response.RemoveComponent.Messages { - messages = append(messages, m.Message) - } - return nil, mutationError("unable to remove component", messages) - } - return toComponent(response.RemoveComponent.Result) -} - -// LinkComponents creates a design-time link between two components. -func LinkComponents(ctx context.Context, mdClient *client.Client, input LinkComponentsInput) (*Link, error) { - response, err := linkComponents(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, input) - if err != nil { - return nil, err - } - if !response.LinkComponents.Successful { - messages := make([]string, 0, len(response.LinkComponents.Messages)) - for _, m := range response.LinkComponents.Messages { - messages = append(messages, m.Message) - } - return nil, mutationError("unable to link components", messages) - } - return toLink(response.LinkComponents.Result) -} - -// UnlinkComponents removes a link by its ID. -func UnlinkComponents(ctx context.Context, mdClient *client.Client, linkID string) (*Link, error) { - response, err := unlinkComponents(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, linkID) - if err != nil { - return nil, err - } - if !response.UnlinkComponents.Successful { - messages := make([]string, 0, len(response.UnlinkComponents.Messages)) - for _, m := range response.UnlinkComponents.Messages { - messages = append(messages, m.Message) - } - return nil, mutationError("unable to unlink components", messages) - } - return toLink(response.UnlinkComponents.Result) -} - -func toComponent(v any) (*Component, error) { - c := Component{} - if err := decode(v, &c); err != nil { - return nil, fmt.Errorf("failed to decode component: %w", err) - } - return &c, nil -} - -func toLink(v any) (*Link, error) { - l := Link{} - if err := decode(v, &l); err != nil { - return nil, fmt.Errorf("failed to decode link: %w", err) - } - return &l, nil -} - -func mutationError(prefix string, messages []string) error { - if len(messages) == 0 { - return errors.New(prefix) - } - var sb strings.Builder - sb.WriteString(prefix) - sb.WriteString(":") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg) - } - return errors.New(sb.String()) -} diff --git a/internal/api/component_test.go b/internal/api/component_test.go deleted file mode 100644 index b20a1eb1..00000000 --- a/internal/api/component_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package api_test - -import ( - "testing" - - api "github.com/massdriver-cloud/mass/internal/api" - "github.com/massdriver-cloud/mass/internal/gqlmock" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -func TestListLinks(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "project": map[string]any{ - "blueprint": map[string]any{ - "links": map[string]any{ - "cursor": map[string]any{}, - "items": []map[string]any{ - { - "id": "link-1", - "fromField": "authentication", - "toField": "database", - "fromComponent": map[string]any{"id": "ecomm-db"}, - "toComponent": map[string]any{"id": "ecomm-app"}, - }, - }, - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - filter := &api.LinksFilter{ - FromComponentId: &api.IdFilter{Eq: "ecomm-db"}, - ToComponentId: &api.IdFilter{Eq: "ecomm-app"}, - } - links, err := api.ListLinks(t.Context(), &mdClient, "ecomm", filter) - if err != nil { - t.Fatal(err) - } - - if len(links) != 1 { - t.Fatalf("got %d links, wanted 1", len(links)) - } - if links[0].FromField != "authentication" || links[0].ToField != "database" { - t.Errorf("got %s→%s, wanted authentication→database", links[0].FromField, links[0].ToField) - } -} - -func TestAddComponent(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "addComponent": map[string]any{ - "result": map[string]any{ - "id": "ecomm-db", - "name": "Primary Database", - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - comp, err := api.AddComponent(t.Context(), &mdClient, "ecomm", "aws-rds-cluster", api.AddComponentInput{ - Id: "db", - Name: "Primary Database", - }) - if err != nil { - t.Fatal(err) - } - - if comp.ID != "ecomm-db" { - t.Errorf("got ID %s, wanted ecomm-db", comp.ID) - } -} - -func TestAddComponentFailure(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "addComponent": map[string]any{ - "result": nil, - "successful": false, - "messages": []map[string]any{ - {"code": "validation", "field": "id", "message": "id is required"}, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - _, err := api.AddComponent(t.Context(), &mdClient, "ecomm", "aws-rds-cluster", api.AddComponentInput{}) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestRemoveComponent(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "removeComponent": map[string]any{ - "result": map[string]any{"id": "ecomm-db", "name": "db"}, - "successful": true, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - comp, err := api.RemoveComponent(t.Context(), &mdClient, "ecomm-db") - if err != nil { - t.Fatal(err) - } - if comp.ID != "ecomm-db" { - t.Errorf("got %s, wanted ecomm-db", comp.ID) - } -} - -func TestLinkComponents(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "linkComponents": map[string]any{ - "result": map[string]any{ - "id": "link-new", - "fromField": "authentication", - "toField": "database", - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - link, err := api.LinkComponents(t.Context(), &mdClient, api.LinkComponentsInput{ - FromComponentId: "ecomm-db", - FromField: "authentication", - FromVersion: "~1.0", - ToComponentId: "ecomm-app", - ToField: "database", - ToVersion: "~2.0", - }) - if err != nil { - t.Fatal(err) - } - - if link.ID != "link-new" { - t.Errorf("got %s, wanted link-new", link.ID) - } -} - -func TestUnlinkComponents(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "unlinkComponents": map[string]any{ - "result": map[string]any{ - "id": "link-1", - "fromField": "authentication", - "toField": "database", - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - link, err := api.UnlinkComponents(t.Context(), &mdClient, "link-1") - if err != nil { - t.Fatal(err) - } - if link.ID != "link-1" { - t.Errorf("got %s, wanted link-1", link.ID) - } -} diff --git a/internal/api/cost.go b/internal/api/cost.go deleted file mode 100644 index d82368e1..00000000 --- a/internal/api/cost.go +++ /dev/null @@ -1,15 +0,0 @@ -package api - -// CostSummary holds cost data for a resource. -type CostSummary struct { - LastMonth CostSample `json:"lastMonth" mapstructure:"lastMonth"` - MonthlyAverage CostSample `json:"monthlyAverage" mapstructure:"monthlyAverage"` - LastDay CostSample `json:"lastDay" mapstructure:"lastDay"` - DailyAverage CostSample `json:"dailyAverage" mapstructure:"dailyAverage"` -} - -// CostSample is a single cost measurement. Fields may be null when no cost data exists. -type CostSample struct { - Amount *float64 `json:"amount" mapstructure:"amount"` - Currency *string `json:"currency" mapstructure:"currency"` -} diff --git a/internal/api/cursor.go b/internal/api/cursor.go deleted file mode 100644 index c22580a9..00000000 --- a/internal/api/cursor.go +++ /dev/null @@ -1,13 +0,0 @@ -package api - -// Cursor is the GraphQL `Cursor` input type. Defined here (rather than letting -// genqlient generate it) so that the `omitempty` tags drop zero-value fields — -// otherwise paginated requests send `limit: 0`, which the server rejects since -// `Cursor.limit` is constrained to 1..100. -// -// Bound to the GraphQL `Cursor` type via genqlient.yaml. -type Cursor struct { - Limit int `json:"limit,omitempty"` - Next string `json:"next,omitempty"` - Previous string `json:"previous,omitempty"` -} diff --git a/internal/api/decode.go b/internal/api/decode.go deleted file mode 100644 index 768a6918..00000000 --- a/internal/api/decode.go +++ /dev/null @@ -1,62 +0,0 @@ -package api - -import ( - "reflect" - "time" - - "github.com/mitchellh/mapstructure" -) - -// decode copies a genqlient-generated struct into one of our exported API structs. -// We use mapstructure because it correctly handles custom-scalar maps (Map, JSON) that -// our genqlient bindings already decode to map[string]any — JSON roundtripping instead -// would re-invoke MarshalJSON on those and produce the doubly-encoded wire form. -// -// But mapstructure treats time.Time as a generic struct: it tries to flatten it into a -// map (hitting only unexported fields → empty map) and then re-inflate it. Both passes -// lose the value. The decode hook below intercepts each direction so timestamps survive. -func decode(input, output any) error { - cfg := &mapstructure.DecoderConfig{ - Result: output, - DecodeHook: timeWrapHook, - } - dec, err := mapstructure.NewDecoder(cfg) - if err != nil { - return err - } - return dec.Decode(input) -} - -const timeWrapKey = "__time__" - -var ( - timeType = reflect.TypeOf(time.Time{}) - timePtrType = reflect.TypeOf(&time.Time{}) -) - -// timeWrapHook preserves time.Time values through mapstructure's internal struct→map→struct -// dance by wrapping them in a single-key map on the way out and unwrapping on the way in. -func timeWrapHook(from, to reflect.Type, data any) (any, error) { - if from == timeType || from == timePtrType { - var t time.Time - switch d := data.(type) { - case time.Time: - t = d - case *time.Time: - if d != nil { - t = *d - } - default: - return data, nil - } - return map[string]any{timeWrapKey: t.Format(time.RFC3339Nano)}, nil - } - if to == timeType { - if m, ok := data.(map[string]any); ok { - if s, ok := m[timeWrapKey].(string); ok { - return time.Parse(time.RFC3339Nano, s) - } - } - } - return data, nil -} diff --git a/internal/api/deployment.go b/internal/api/deployment.go deleted file mode 100644 index ad690b99..00000000 --- a/internal/api/deployment.go +++ /dev/null @@ -1,129 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -// Deployment represents a record of an infrastructure provisioning operation. -type Deployment struct { - ID string `json:"id" mapstructure:"id"` - Status string `json:"status" mapstructure:"status"` - Action string `json:"action" mapstructure:"action"` - Version string `json:"version" mapstructure:"version"` - Message string `json:"message,omitempty" mapstructure:"message"` - Params map[string]any `json:"params,omitempty" mapstructure:"params"` - DeployedBy string `json:"deployedBy,omitempty" mapstructure:"deployedBy"` - ElapsedTime int `json:"elapsedTime" mapstructure:"elapsedTime"` - CreatedAt time.Time `json:"createdAt,omitzero" mapstructure:"createdAt"` - UpdatedAt time.Time `json:"updatedAt,omitzero" mapstructure:"updatedAt"` - LastTransitionedAt time.Time `json:"lastTransitionedAt,omitzero" mapstructure:"lastTransitionedAt"` - Instance *Instance `json:"instance,omitempty" mapstructure:"instance,omitempty"` -} - -// ParamsJSON returns the deployment's snapshot parameters as pretty-printed JSON. -func (d *Deployment) ParamsJSON() (string, error) { - if d.Params == nil { - return "{}", nil - } - b, err := json.MarshalIndent(d.Params, "", " ") - if err != nil { - return "", fmt.Errorf("failed to marshal params to JSON: %w", err) - } - return string(b), nil -} - -// DeploymentLog is a single batch of logs emitted by the provisioner during a deployment. -// The message may span multiple lines separated by "\n". -type DeploymentLog struct { - Timestamp time.Time `json:"timestamp,omitzero" mapstructure:"timestamp"` - Message string `json:"message" mapstructure:"message"` -} - -// GetDeployment retrieves a deployment by ID from the Massdriver API. -func GetDeployment(ctx context.Context, mdClient *client.Client, id string) (*Deployment, error) { - response, err := getDeployment(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id) - if err != nil { - return nil, fmt.Errorf("failed to get deployment %s: %w", id, err) - } - - return toDeployment(response.Deployment) -} - -// ListDeployments returns deployments, optionally filtered and sorted. If limit > 0, at most -// that many records are returned (capped by the server's cursor max, currently 100). -func ListDeployments(ctx context.Context, mdClient *client.Client, filter *DeploymentsFilter, sort *DeploymentsSort, limit int) ([]Deployment, error) { - var cursor *Cursor - if limit > 0 { - cursor = &Cursor{Limit: limit} - } - response, err := listDeployments(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, filter, sort, cursor) - if err != nil { - return nil, fmt.Errorf("failed to list deployments: %w", err) - } - - deployments := make([]Deployment, 0, len(response.Deployments.Items)) - for _, resp := range response.Deployments.Items { - dep, depErr := toDeployment(resp) - if depErr != nil { - return nil, fmt.Errorf("failed to convert deployment: %w", depErr) - } - deployments = append(deployments, *dep) - } - - return deployments, nil -} - -// GetDeploymentLogs returns all log batches emitted for the given deployment so far, oldest first. -func GetDeploymentLogs(ctx context.Context, mdClient *client.Client, deploymentID string) ([]DeploymentLog, error) { - response, err := getDeploymentLogs(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, deploymentID) - if err != nil { - return nil, fmt.Errorf("failed to get logs for deployment %s: %w", deploymentID, err) - } - - logs := make([]DeploymentLog, 0, len(response.Deployment.Logs)) - for _, l := range response.Deployment.Logs { - log := DeploymentLog{} - if decodeErr := decode(l, &log); decodeErr != nil { - return nil, fmt.Errorf("failed to decode deployment log: %w", decodeErr) - } - logs = append(logs, log) - } - return logs, nil -} - -// CreateDeployment starts a new deployment for an instance. -func CreateDeployment(ctx context.Context, mdClient *client.Client, instanceID string, input CreateDeploymentInput) (*Deployment, error) { - response, err := createDeployment(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, instanceID, input) - if err != nil { - return nil, err - } - if !response.CreateDeployment.Successful { - messages := response.CreateDeployment.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to create deployment:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to create deployment") - } - return toDeployment(response.CreateDeployment.Result) -} - -func toDeployment(v any) (*Deployment, error) { - dep := Deployment{} - if err := decode(v, &dep); err != nil { - return nil, fmt.Errorf("failed to decode deployment: %w", err) - } - return &dep, nil -} diff --git a/internal/api/deployment_test.go b/internal/api/deployment_test.go deleted file mode 100644 index 190c26e5..00000000 --- a/internal/api/deployment_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package api_test - -import ( - "testing" - - api "github.com/massdriver-cloud/mass/internal/api" - "github.com/massdriver-cloud/mass/internal/gqlmock" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -func TestGetDeployment(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "deployment": map[string]any{ - "id": "dep-uuid1", - "status": "COMPLETED", - "action": "PROVISION", - "version": "1.2.3", - "instance": map[string]any{ - "id": "inst-1", - "name": "db", - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - dep, err := api.GetDeployment(t.Context(), &mdClient, "dep-uuid1") - if err != nil { - t.Fatal(err) - } - - if dep.ID != "dep-uuid1" { - t.Errorf("got %s, wanted dep-uuid1", dep.ID) - } - if dep.Status != "COMPLETED" { - t.Errorf("got %s, wanted COMPLETED", dep.Status) - } - if dep.Instance == nil || dep.Instance.ID != "inst-1" { - t.Errorf("expected instance inst-1") - } -} - -func TestListDeployments(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "deployments": map[string]any{ - "cursor": map[string]any{}, - "items": []map[string]any{ - {"id": "dep-1", "status": "COMPLETED", "action": "PROVISION"}, - {"id": "dep-2", "status": "RUNNING", "action": "PROVISION"}, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - deployments, err := api.ListDeployments(t.Context(), &mdClient, nil, nil, 0) - if err != nil { - t.Fatal(err) - } - - if len(deployments) != 2 { - t.Errorf("got %d deployments, wanted 2", len(deployments)) - } -} - -func TestCreateDeployment(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "createDeployment": map[string]any{ - "result": map[string]any{ - "id": "dep-new", - "status": "PENDING", - "action": "PROVISION", - "version": "1.2.3", - "message": "Initial deployment", - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - dep, err := api.CreateDeployment(t.Context(), &mdClient, "inst-1", api.CreateDeploymentInput{ - Action: api.DeploymentActionProvision, - Message: "Initial deployment", - Params: map[string]any{}, - }) - if err != nil { - t.Fatal(err) - } - - if dep.ID != "dep-new" { - t.Errorf("got %s, wanted dep-new", dep.ID) - } -} - -func TestCreateDeploymentFailure(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "createDeployment": map[string]any{ - "result": nil, - "successful": false, - "messages": []map[string]any{ - { - "code": "invalid", - "field": "params", - "message": "params failed validation", - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - _, err := api.CreateDeployment(t.Context(), &mdClient, "inst-1", api.CreateDeploymentInput{ - Action: api.DeploymentActionProvision, - Params: map[string]any{}, - }) - if err == nil { - t.Fatal("expected error, got nil") - } -} diff --git a/internal/api/environment.go b/internal/api/environment.go deleted file mode 100644 index dbb5d20c..00000000 --- a/internal/api/environment.go +++ /dev/null @@ -1,191 +0,0 @@ -package api - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -// Environment represents a Massdriver deployment environment within a project. -type Environment struct { - ID string `json:"id" mapstructure:"id"` - Name string `json:"name" mapstructure:"name"` - Description string `json:"description,omitempty" mapstructure:"description"` - Cost CostSummary `json:"cost" mapstructure:"cost"` - Attributes map[string]string `json:"attributes,omitempty" mapstructure:"attributes"` - Project *Project `json:"project,omitempty" mapstructure:"project,omitempty"` - Blueprint *Blueprint `json:"blueprint,omitempty" mapstructure:"-"` -} - -// GetEnvironment retrieves an environment by ID from the Massdriver API. -func GetEnvironment(ctx context.Context, mdClient *client.Client, id string) (*Environment, error) { - response, err := getEnvironment(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id) - if err != nil { - return nil, fmt.Errorf("failed to get environment %s: %w", id, err) - } - - return toEnvironment(response.Environment) -} - -// ListEnvironments returns environments, optionally filtered. -func ListEnvironments(ctx context.Context, mdClient *client.Client, filter *EnvironmentsFilter) ([]Environment, error) { - response, err := listEnvironments(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, filter, nil, nil) - if err != nil { - return nil, fmt.Errorf("failed to list environments: %w", err) - } - - envs := make([]Environment, 0, len(response.Environments.Items)) - for _, resp := range response.Environments.Items { - env, envErr := toEnvironment(resp) - if envErr != nil { - return nil, fmt.Errorf("failed to convert environment: %w", envErr) - } - envs = append(envs, *env) - } - - return envs, nil -} - -func toEnvironment(v any) (*Environment, error) { - env := Environment{} - if err := decode(v, &env); err != nil { - return nil, fmt.Errorf("failed to decode environment: %w", err) - } - - // Unwrap paginated blueprint.instances (API returns {blueprint: {instances: {items: [...]}}}) - type instPage struct { - Items []Instance `json:"items"` - } - type blueprint struct { - Instances instPage `json:"instances"` - } - type hasBP struct { - Blueprint blueprint `json:"blueprint"` - } - var wrapper hasBP - if err := decode(v, &wrapper); err == nil && len(wrapper.Blueprint.Instances.Items) > 0 { - env.Blueprint = &Blueprint{ - Instances: wrapper.Blueprint.Instances.Items, - } - } - - return &env, nil -} - -// CreateEnvironment creates a new environment within the given project. -func CreateEnvironment(ctx context.Context, mdClient *client.Client, projectID string, input CreateEnvironmentInput) (*Environment, error) { - response, err := createEnvironment(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, projectID, input) - if err != nil { - return nil, err - } - if !response.CreateEnvironment.Successful { - messages := response.CreateEnvironment.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to create environment:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to create environment") - } - return toEnvironment(response.CreateEnvironment.Result) -} - -// UpdateEnvironment updates an environment in the Massdriver API. -func UpdateEnvironment(ctx context.Context, mdClient *client.Client, id string, input UpdateEnvironmentInput) (*Environment, error) { - response, err := updateEnvironment(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id, input) - if err != nil { - return nil, err - } - if !response.UpdateEnvironment.Successful { - messages := response.UpdateEnvironment.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to update environment:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to update environment") - } - return toEnvironment(response.UpdateEnvironment.Result) -} - -// DeleteEnvironment removes an environment by ID from the Massdriver API. -func DeleteEnvironment(ctx context.Context, mdClient *client.Client, id string) (*Environment, error) { - response, err := deleteEnvironment(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id) - if err != nil { - return nil, err - } - if !response.DeleteEnvironment.Successful { - messages := response.DeleteEnvironment.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to delete environment:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to delete environment") - } - return toEnvironment(response.DeleteEnvironment.Result) -} - -// EnvironmentDefault is a default resource for an environment. Instances in -// the environment automatically inherit defaults for their required resource types. -type EnvironmentDefault struct { - ID string `json:"id" mapstructure:"id"` - Resource EnvironmentDefaultResource `json:"resource" mapstructure:"resource"` - CreatedAt time.Time `json:"createdAt,omitzero" mapstructure:"createdAt"` - UpdatedAt time.Time `json:"updatedAt,omitzero" mapstructure:"updatedAt"` -} - -// EnvironmentDefaultResource is a resource referenced by an environment default. -type EnvironmentDefaultResource struct { - ID string `json:"id" mapstructure:"id"` - Name string `json:"name" mapstructure:"name"` - ResourceType *ResourceType `json:"resourceType,omitempty" mapstructure:"resourceType,omitempty"` -} - -// SetEnvironmentDefault sets a resource as the default of its type for an environment. -// All instances in the environment will automatically inherit this resource. Only one -// resource per type can be the default — remove the existing default first to change it. -func SetEnvironmentDefault(ctx context.Context, mdClient *client.Client, environmentID, resourceID string) (*EnvironmentDefault, error) { - response, err := setEnvironmentDefault(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, environmentID, resourceID) - if err != nil { - return nil, err - } - if !response.SetEnvironmentDefault.Successful { - messages := response.SetEnvironmentDefault.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to set environment default:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to set environment default") - } - return toEnvironmentDefault(response.SetEnvironmentDefault.Result) -} - -func toEnvironmentDefault(v any) (*EnvironmentDefault, error) { - ed := EnvironmentDefault{} - if err := decode(v, &ed); err != nil { - return nil, fmt.Errorf("failed to decode environment default: %w", err) - } - return &ed, nil -} diff --git a/internal/api/environment_test.go b/internal/api/environment_test.go deleted file mode 100644 index b930c9e9..00000000 --- a/internal/api/environment_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package api_test - -import ( - "testing" - - api "github.com/massdriver-cloud/mass/internal/api" - "github.com/massdriver-cloud/mass/internal/gqlmock" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -func TestGetEnvironment(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "environment": map[string]any{ - "id": "env-uuid1", - "name": "Production", - "description": "Production environment", - "project": map[string]any{ - "id": "proj-1", - "name": "My Project", - }, - }, - }, - }) - mdClient := client.Client{ - GQLv2: gqlClient, - } - - env, err := api.GetEnvironment(t.Context(), &mdClient, "env-uuid1") - if err != nil { - t.Fatal(err) - } - - if env.ID != "env-uuid1" { - t.Errorf("got %s, wanted env-uuid1", env.ID) - } - if env.Name != "Production" { - t.Errorf("got %s, wanted Production", env.Name) - } - if env.Project == nil || env.Project.ID != "proj-1" { - t.Errorf("expected project with ID proj-1") - } -} - -func TestListEnvironments(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "environments": map[string]any{ - "cursor": map[string]any{}, - "items": []map[string]any{ - { - "id": "env-1", - "name": "staging", - }, - { - "id": "env-2", - "name": "production", - }, - }, - }, - }, - }) - mdClient := client.Client{ - GQLv2: gqlClient, - } - - envs, err := api.ListEnvironments(t.Context(), &mdClient, nil) - if err != nil { - t.Fatal(err) - } - - if len(envs) != 2 { - t.Errorf("got %d environments, wanted 2", len(envs)) - } -} - -func TestCreateEnvironment(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "createEnvironment": map[string]any{ - "result": map[string]any{ - "id": "env-new", - "name": "Staging", - "description": "Staging environment", - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{ - GQLv2: gqlClient, - } - - env, err := api.CreateEnvironment(t.Context(), &mdClient, "proj-1", api.CreateEnvironmentInput{ - Id: "staging", - Name: "Staging", - Description: "Staging environment", - }) - if err != nil { - t.Fatal(err) - } - - if env.ID != "env-new" { - t.Errorf("got %s, wanted env-new", env.ID) - } -} - -func TestSetEnvironmentDefault(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "setEnvironmentDefault": map[string]any{ - "result": map[string]any{ - "id": "envdef-1", - "resource": map[string]any{ - "id": "res-1", - "name": "default-vpc", - "resourceType": map[string]any{ - "id": "aws-vpc", - "name": "AWS VPC", - }, - }, - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{ - GQLv2: gqlClient, - } - - envDefault, err := api.SetEnvironmentDefault(t.Context(), &mdClient, "env-1", "res-1") - if err != nil { - t.Fatal(err) - } - - if envDefault.ID != "envdef-1" { - t.Errorf("got %s, wanted envdef-1", envDefault.ID) - } - if envDefault.Resource.ID != "res-1" { - t.Errorf("got resource ID %s, wanted res-1", envDefault.Resource.ID) - } - if envDefault.Resource.Name != "default-vpc" { - t.Errorf("got resource name %s, wanted default-vpc", envDefault.Resource.Name) - } - if envDefault.Resource.ResourceType == nil || envDefault.Resource.ResourceType.ID != "aws-vpc" { - t.Errorf("expected resource type aws-vpc") - } -} - -func TestSetEnvironmentDefaultFailure(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "setEnvironmentDefault": map[string]any{ - "result": nil, - "successful": false, - "messages": []map[string]any{ - { - "code": "conflict", - "field": "resourceId", - "message": "a default of this resource type already exists", - }, - }, - }, - }, - }) - mdClient := client.Client{ - GQLv2: gqlClient, - } - - _, err := api.SetEnvironmentDefault(t.Context(), &mdClient, "env-1", "res-1") - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestDeleteEnvironment(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "deleteEnvironment": map[string]any{ - "result": map[string]any{ - "id": "env-1", - "name": "Staging", - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{ - GQLv2: gqlClient, - } - - env, err := api.DeleteEnvironment(t.Context(), &mdClient, "env-1") - if err != nil { - t.Fatal(err) - } - - if env.ID != "env-1" { - t.Errorf("got %s, wanted env-1", env.ID) - } -} diff --git a/internal/api/error.go b/internal/api/error.go deleted file mode 100644 index cb710a49..00000000 --- a/internal/api/error.go +++ /dev/null @@ -1,33 +0,0 @@ -package api - -import ( - "fmt" -) - -// MutationError represents an error returned from a GraphQL mutation, including validation messages. -type MutationError struct { - Err string - Messages []MutationValidationError -} - -// MutationValidationError represents a single validation error from a mutation. -type MutationValidationError struct { - Code string - Field string - Message string -} - -func (m *MutationError) Error() string { - err := fmt.Sprintf("GraphQL mutation %s\n", m.Err) - - for _, msg := range m.Messages { - err = fmt.Sprintf("%s - %s\n", err, msg.Message) - } - - return err -} - -// NewMutationError creates a new MutationError with the given message and validation errors. -func NewMutationError(msg string, validationErrors []MutationValidationError) error { - return &MutationError{Err: msg, Messages: validationErrors} -} diff --git a/internal/api/genqlient.graphql b/internal/api/genqlient.graphql deleted file mode 100644 index 1a867800..00000000 --- a/internal/api/genqlient.graphql +++ /dev/null @@ -1,1292 +0,0 @@ -# SERVER / VIEWER - -query getViewer { - viewer { - __typename - ... on AccountViewer { - id - email - firstName - lastName - defaultOrganization { - id - name - } - } - ... on ServiceAccountViewer { - id - name - description - organization { - id - name - } - } - } -} - -query getServer { - server { - appUrl - version - mode - ssoProviders { - name - loginUrl - uiIconUrl - uiLabel - } - emailAuthMethods { - name - } - } -} - - -# PROJECTS - -query listProjects($organizationId: ID!) { - projects(organizationId: $organizationId, sort: {field: NAME, order: ASC}) { - items { - id - name - description - attributes - createdAt - updatedAt - cost { - monthlyAverage { - amount - currency - } - dailyAverage { - amount - currency - } - } - } - } -} - -query getProject($organizationId: ID!, $id: ID!) { - project(organizationId: $organizationId, id: $id) { - id - name - description - attributes - environments { - items { - id - name - description - createdAt - updatedAt - cost { - monthlyAverage { - amount - currency - } - dailyAverage { - amount - currency - } - } - } - } - blueprint { - components { - items { - id - name - description - attributes - createdAt - updatedAt - ociRepo { - id - name - } - } - } - } - createdAt - updatedAt - deletable { - result - } - cost { - monthlyAverage { - amount - currency - } - dailyAverage { - amount - currency - } - } - } -} - -mutation createProject($organizationId: ID!, $input: CreateProjectInput!) { - createProject(organizationId: $organizationId, input: $input) { - result { - id - name - description - } - successful - messages { - code - field - message - } - } -} - -mutation updateProject($organizationId: ID!, $id: ID!, $input: UpdateProjectInput!) { - updateProject(organizationId: $organizationId, id: $id, input: $input) { - result { - id - name - description - } - successful - messages { - code - field - message - } - } -} - -mutation deleteProject($organizationId: ID!, $id: ID!) { - deleteProject(organizationId: $organizationId, id: $id) { - result { - id - name - description - } - successful - messages { - code - field - message - } - } -} - - -# COMPONENTS - -# @genqlient(for: "LinksFilter.fromComponentId", omitempty: true, pointer: true) -# @genqlient(for: "LinksFilter.toComponentId", omitempty: true, pointer: true) -query listLinks( - $organizationId: ID!, - $projectId: ID!, - # @genqlient(omitempty: true, pointer: true) - $filter: LinksFilter, - # @genqlient(omitempty: true, pointer: true) - $cursor: Cursor -) { - project(organizationId: $organizationId, id: $projectId) { - blueprint { - links(filter: $filter, cursor: $cursor) { - cursor { - next - previous - } - items { - id - fromField - toField - createdAt - updatedAt - fromComponent { - id - name - description - attributes - createdAt - updatedAt - } - toComponent { - id - name - description - attributes - createdAt - updatedAt - } - } - } - } - } -} - -query getComponent($organizationId: ID!, $id: ID!) { - component(organizationId: $organizationId, id: $id) { - id - name - description - attributes - createdAt - updatedAt - } -} - -# @genqlient(for: "AddComponentInput.description", omitempty: true) -# @genqlient(for: "AddComponentInput.attributes", omitempty: true) -mutation addComponent( - $organizationId: ID!, - $projectId: ID!, - $ociRepoName: OciRepoName!, - $input: AddComponentInput! -) { - addComponent( - organizationId: $organizationId, - projectId: $projectId, - ociRepoName: $ociRepoName, - input: $input - ) { - result { - id - name - description - attributes - createdAt - updatedAt - ociRepo { - id - name - } - } - successful - messages { - code - field - message - } - } -} - -mutation removeComponent($organizationId: ID!, $id: ID!) { - removeComponent(organizationId: $organizationId, id: $id) { - result { - id - name - } - successful - messages { - code - field - message - } - } -} - -mutation updateComponent($organizationId: ID!, $id: ID!, $input: UpdateComponentInput!) { - updateComponent(organizationId: $organizationId, id: $id, input: $input) { - result { - id - name - description - } - successful - messages { - code - field - message - } - } -} - -mutation linkComponents($organizationId: ID!, $input: LinkComponentsInput!) { - linkComponents(organizationId: $organizationId, input: $input) { - result { - id - fromField - toField - createdAt - updatedAt - fromComponent { - id - name - description - attributes - createdAt - updatedAt - } - toComponent { - id - name - description - attributes - createdAt - updatedAt - } - } - successful - messages { - code - field - message - } - } -} - -mutation unlinkComponents($organizationId: ID!, $id: UUID!) { - unlinkComponents(organizationId: $organizationId, id: $id) { - result { - id - fromField - toField - } - successful - messages { - code - field - message - } - } -} - - -# ENVIRONMENTS - -# @genqlient(for: "EnvironmentsFilter.projectId", omitempty: true, pointer: true) -# @genqlient(for: "EnvironmentsFilter.id", omitempty: true, pointer: true) -# @genqlient(for: "IdFilter.eq", omitempty: true) -# @genqlient(for: "IdFilter.in", omitempty: true) -# @genqlient(for: "StringFilter.eq", omitempty: true) -# @genqlient(for: "StringFilter.in", omitempty: true) -query listEnvironments( - $organizationId: ID!, - # @genqlient(omitempty: true, pointer: true) - $filter: EnvironmentsFilter, - # @genqlient(omitempty: true, pointer: true) - $sort: EnvironmentsSort, - # @genqlient(omitempty: true, pointer: true) - $cursor: Cursor -) { - environments(organizationId: $organizationId, filter: $filter, sort: $sort, cursor: $cursor) { - cursor { - next - previous - } - items { - id - name - description - attributes - createdAt - updatedAt - project { - id - name - description - } - cost { - monthlyAverage { - amount - currency - } - dailyAverage { - amount - currency - } - } - } - } -} - -query getEnvironment($organizationId: ID!, $id: ID!) { - environment(organizationId: $organizationId, id: $id) { - id - name - description - attributes - createdAt - updatedAt - cost { - monthlyAverage { - amount - currency - } - dailyAverage { - amount - currency - } - } - project { - id - name - description - } - blueprint { - instances { - cursor { - next - previous - } - items { - id - name - status - version - releaseStrategy - attributes - createdAt - updatedAt - component { - id - name - description - attributes - createdAt - updatedAt - } - bundle { - id - name - version - description - icon - sourceUrl - repo - createdAt - updatedAt - } - } - } - } - } -} - -mutation createEnvironment($organizationId: ID!, $projectId: ID!, $input: CreateEnvironmentInput!) { - createEnvironment(organizationId: $organizationId, projectId: $projectId, input: $input) { - result { - id - name - description - } - successful - messages { - code - field - message - } - } -} - -mutation updateEnvironment($organizationId: ID!, $id: ID!, $input: UpdateEnvironmentInput!) { - updateEnvironment(organizationId: $organizationId, id: $id, input: $input) { - result { - id - name - description - } - successful - messages { - code - field - message - } - } -} - -mutation deleteEnvironment($organizationId: ID!, $id: ID!) { - deleteEnvironment(organizationId: $organizationId, id: $id) { - result { - id - name - description - } - successful - messages { - code - field - message - } - } -} - -mutation setEnvironmentDefault($organizationId: ID!, $environmentId: ID!, $resourceId: ID!) { - setEnvironmentDefault(organizationId: $organizationId, environmentId: $environmentId, resourceId: $resourceId) { - result { - id - createdAt - updatedAt - resource { - id - name - resourceType { - id - name - icon - connectionOrientation - createdAt - updatedAt - } - } - } - successful - messages { - code - field - message - } - } -} - - -# INSTANCES - -# @genqlient(for: "InstancesFilter.projectId", omitempty: true, pointer: true) -# @genqlient(for: "InstancesFilter.environmentId", omitempty: true, pointer: true) -# @genqlient(for: "InstancesFilter.status", omitempty: true, pointer: true) -# @genqlient(for: "InstancesFilter.ociRepoName", omitempty: true, pointer: true) -# @genqlient(for: "InstancesFilter.paramDimension", omitempty: true) -# @genqlient(for: "IdFilter.eq", omitempty: true) -# @genqlient(for: "IdFilter.in", omitempty: true) -# @genqlient(for: "InstanceStatusFilter.eq", omitempty: true) -# @genqlient(for: "InstanceStatusFilter.in", omitempty: true) -# @genqlient(for: "OciRepoNameFilter.eq", omitempty: true) -# @genqlient(for: "OciRepoNameFilter.in", omitempty: true) -# @genqlient(for: "OciRepoNameFilter.startsWith", omitempty: true) -# @genqlient(for: "ParamDimensionFilter.eq", omitempty: true) -# @genqlient(for: "ParamDimensionFilter.in", omitempty: true) -# @genqlient(for: "ParamDimensionFilter.contains", omitempty: true) -query listInstances( - $organizationId: ID!, - # @genqlient(omitempty: true, pointer: true) - $filter: InstancesFilter, - # @genqlient(omitempty: true, pointer: true) - $sort: InstancesSort, - # @genqlient(omitempty: true, pointer: true) - $cursor: Cursor -) { - instances(organizationId: $organizationId, filter: $filter, sort: $sort, cursor: $cursor) { - cursor { - next - previous - } - items { - id - name - status - version - releaseStrategy - resolvedVersion - deployedVersion - availableUpgrade - attributes - createdAt - updatedAt - cost { - monthlyAverage { - amount - currency - } - dailyAverage { - amount - currency - } - } - environment { - id - name - project { - id - name - } - } - bundle { - id - name - version - } - component { - id - name - description - } - } - } -} - -query getInstance($organizationId: ID!, $id: ID!) { - instance(organizationId: $organizationId, id: $id) { - id - name - status - params - attributes - version - releaseStrategy - resolvedVersion - deployedVersion - availableUpgrade - createdAt - updatedAt - cost { - monthlyAverage { - amount - currency - } - dailyAverage { - amount - currency - } - } - environment { - id - name - description - project { - id - name - description - } - } - bundle { - id - name - version - description - icon - sourceUrl - repo - createdAt - updatedAt - } - component { - id - name - description - attributes - createdAt - updatedAt - } - statePaths { - stepName - stateUrl - } - } -} - -query listInstanceResources( - $organizationId: ID!, - $instanceId: ID! -) { - instance(organizationId: $organizationId, id: $instanceId) { - resources { - field - resource { - id - name - origin - createdAt - updatedAt - } - } - } -} - -mutation updateInstance($organizationId: ID!, $id: ID!, $input: UpdateInstanceInput!) { - updateInstance(organizationId: $organizationId, id: $id, input: $input) { - result { - id - name - status - version - releaseStrategy - resolvedVersion - } - successful - messages { - code - field - message - } - } -} - -mutation setInstanceSecret($organizationId: ID!, $id: ID!, $input: SetInstanceSecretInput!) { - setInstanceSecret(organizationId: $organizationId, id: $id, input: $input) { - result { - name - createdAt - updatedAt - } - successful - messages { - code - field - message - } - } -} - -mutation removeInstanceSecret($organizationId: ID!, $id: ID!, $name: String!) { - removeInstanceSecret(organizationId: $organizationId, id: $id, name: $name) { - result { - name - createdAt - updatedAt - } - successful - messages { - code - field - message - } - } -} - - -# DEPLOYMENTS - -# @genqlient(for: "DeploymentsFilter.instanceId", omitempty: true, pointer: true) -# @genqlient(for: "DeploymentsFilter.status", omitempty: true, pointer: true) -# @genqlient(for: "DeploymentsFilter.action", omitempty: true, pointer: true) -# @genqlient(for: "IdFilter.eq", omitempty: true) -# @genqlient(for: "IdFilter.in", omitempty: true) -# @genqlient(for: "DeploymentStatusFilter.eq", omitempty: true) -# @genqlient(for: "DeploymentStatusFilter.in", omitempty: true) -# @genqlient(for: "DeploymentActionFilter.eq", omitempty: true) -# @genqlient(for: "DeploymentActionFilter.in", omitempty: true) -query listDeployments( - $organizationId: ID!, - # @genqlient(omitempty: true, pointer: true) - $filter: DeploymentsFilter, - # @genqlient(omitempty: true, pointer: true) - $sort: DeploymentsSort, - # @genqlient(omitempty: true, pointer: true) - $cursor: Cursor -) { - deployments(organizationId: $organizationId, filter: $filter, sort: $sort, cursor: $cursor) { - cursor { - next - previous - } - items { - id - status - action - version - message - createdAt - updatedAt - lastTransitionedAt - elapsedTime - deployedBy - instance { - id - name - } - } - } -} - -query getDeployment($organizationId: ID!, $id: UUID!) { - deployment(organizationId: $organizationId, id: $id) { - id - status - action - version - message - params - createdAt - updatedAt - lastTransitionedAt - elapsedTime - deployedBy - instance { - id - name - status - environment { - id - name - project { - id - name - } - } - } - } -} - -query getDeploymentLogs($organizationId: ID!, $id: UUID!) { - deployment(organizationId: $organizationId, id: $id) { - id - logs { - timestamp - message - } - } -} - -mutation createDeployment($organizationId: ID!, $id: ID!, $input: CreateDeploymentInput!) { - createDeployment(organizationId: $organizationId, id: $id, input: $input) { - result { - id - status - action - version - message - createdAt - instance { - id - name - } - } - successful - messages { - code - field - message - } - } -} - - -# BUNDLES - -# @genqlient(for: "BundlesFilter.ociRepo", omitempty: true, pointer: true) -# @genqlient(for: "BundlesFilter.resourceType", omitempty: true, pointer: true) -# @genqlient(for: "BundlesFilter.dependencyType", omitempty: true, pointer: true) -# @genqlient(for: "BundlesFilter.search", omitempty: true) -# @genqlient(for: "OciRepoNameFilter.eq", omitempty: true) -# @genqlient(for: "OciRepoNameFilter.in", omitempty: true) -# @genqlient(for: "OciRepoNameFilter.startsWith", omitempty: true) -# @genqlient(for: "StringFilter.eq", omitempty: true) -# @genqlient(for: "StringFilter.in", omitempty: true) -query listBundles( - $organizationId: ID!, - # @genqlient(omitempty: true, pointer: true) - $filter: BundlesFilter, - # @genqlient(omitempty: true, pointer: true) - $sort: BundlesSort, - # @genqlient(omitempty: true, pointer: true) - $cursor: Cursor -) { - bundles(organizationId: $organizationId, filter: $filter, sort: $sort, cursor: $cursor) { - cursor { - next - previous - } - items { - id - name - version - description - icon - sourceUrl - repo - createdAt - updatedAt - } - } -} - -query getBundle($organizationId: ID!, $id: BundleId!) { - bundle(organizationId: $organizationId, id: $id) { - id - name - version - description - icon - sourceUrl - repo - createdAt - updatedAt - dependencies { - name - required - resourceType { - id - name - } - } - resources { - name - required - resourceType { - id - name - } - } - } -} - - -# OCI REPOS - -# @genqlient(for: "OciReposFilter.artifactType", omitempty: true) -# @genqlient(for: "OciReposFilter.name", omitempty: true, pointer: true) -# @genqlient(for: "OciReposFilter.search", omitempty: true) -# @genqlient(for: "OciRepoNameFilter.eq", omitempty: true) -# @genqlient(for: "OciRepoNameFilter.in", omitempty: true) -# @genqlient(for: "OciRepoNameFilter.startsWith", omitempty: true) -query listOciRepos( - $organizationId: ID!, - # @genqlient(omitempty: true, pointer: true) - $filter: OciReposFilter, - # @genqlient(omitempty: true, pointer: true) - $sort: OciReposSort, - # @genqlient(omitempty: true, pointer: true) - $cursor: Cursor -) { - ociRepos(organizationId: $organizationId, filter: $filter, sort: $sort, cursor: $cursor) { - cursor { - next - previous - } - items { - id - name - artifactType - createdAt - updatedAt - releaseChannels { - items { - name - tag - } - } - } - } -} - -query getOciRepo($organizationId: ID!, $id: ID!) { - ociRepo(organizationId: $organizationId, id: $id) { - id - name - artifactType - description - attributes - createdAt - updatedAt - releaseChannels { - items { - name - tag - } - } - tags(sort: { field: VERSION, order: DESC }) { - items { - tag - createdAt - } - } - } -} - -mutation createOciRepo($organizationId: ID!, $input: CreateOciRepoInput!) { - createOciRepo(organizationId: $organizationId, input: $input) { - result { - id - name - artifactType - attributes - } - successful - messages { - code - field - message - } - } -} - -mutation updateOciRepo($organizationId: ID!, $id: ID!, $input: UpdateOciRepoInput!) { - updateOciRepo(organizationId: $organizationId, id: $id, input: $input) { - result { - id - name - artifactType - attributes - } - successful - messages { - code - field - message - } - } -} - -mutation deleteOciRepo($organizationId: ID!, $id: ID!) { - deleteOciRepo(organizationId: $organizationId, id: $id) { - result { - id - name - artifactType - } - successful - messages { - code - field - message - } - } -} - - -# RESOURCE TYPES - -query listResourceTypes( - $organizationId: ID!, - # @genqlient(omitempty: true, pointer: true) - $filter: ResourceTypesFilter, - # @genqlient(omitempty: true, pointer: true) - $sort: ResourceTypesSort, - # @genqlient(omitempty: true, pointer: true) - $cursor: Cursor -) { - resourceTypes(organizationId: $organizationId, filter: $filter, sort: $sort, cursor: $cursor) { - cursor { - next - previous - } - items { - id - name - icon - connectionOrientation - createdAt - updatedAt - } - } -} - -query getResourceType($organizationId: ID!, $id: ID!) { - resourceType(organizationId: $organizationId, id: $id) { - id - name - icon - connectionOrientation - schema - createdAt - updatedAt - } -} - -mutation publishResourceType($organizationId: ID!, $input: PublishResourceTypeInput!) { - publishResourceType(organizationId: $organizationId, input: $input) { - result { - id - name - icon - connectionOrientation - schema - createdAt - updatedAt - } - successful - messages { - code - field - message - } - } -} - -mutation deleteResourceType($organizationId: ID!, $id: ID!) { - deleteResourceType(organizationId: $organizationId, id: $id) { - result { - id - name - } - successful - messages { - code - field - message - } - } -} - - -# RESOURCES - -# @genqlient(for: "ResourcesFilter.origin", omitempty: true, pointer: true) -# @genqlient(for: "ResourceOriginFilter.eq", omitempty: true) -# @genqlient(for: "ResourceOriginFilter.in", omitempty: true) -# @genqlient(for: "CreateResourceInput.payload", omitempty: true) -# @genqlient(for: "UpdateResourceInput.name", omitempty: true) -# @genqlient(for: "UpdateResourceInput.payload", omitempty: true) -query listResources( - $organizationId: ID!, - # @genqlient(omitempty: true, pointer: true) - $filter: ResourcesFilter, - # @genqlient(omitempty: true, pointer: true) - $sort: ResourcesSort, - # @genqlient(omitempty: true, pointer: true) - $cursor: Cursor -) { - resources(organizationId: $organizationId, filter: $filter, sort: $sort, cursor: $cursor) { - cursor { - next - previous - } - items { - id - name - origin - resourceType { - id - name - icon - connectionOrientation - createdAt - updatedAt - } - field - instance { - id - name - status - version - releaseStrategy - createdAt - updatedAt - } - formats - createdAt - updatedAt - } - } -} - -query getResource($organizationId: ID!, $id: ID!) { - resource(organizationId: $organizationId, id: $id) { - id - name - origin - resourceType { - id - name - icon - connectionOrientation - createdAt - updatedAt - } - field - instance { - id - name - status - version - releaseStrategy - createdAt - updatedAt - } - formats - payload - createdAt - updatedAt - } -} - -mutation createResource($organizationId: ID!, $resourceTypeId: ID!, $input: CreateResourceInput!) { - createResource(organizationId: $organizationId, resourceTypeId: $resourceTypeId, input: $input) { - result { - id - name - origin - resourceType { - id - name - icon - connectionOrientation - createdAt - updatedAt - } - createdAt - updatedAt - } - successful - messages { - code - field - message - } - } -} - -mutation updateResource($organizationId: ID!, $id: ID!, $input: UpdateResourceInput!) { - updateResource(organizationId: $organizationId, id: $id, input: $input) { - result { - id - name - origin - resourceType { - id - name - icon - connectionOrientation - createdAt - updatedAt - } - createdAt - updatedAt - } - successful - messages { - code - field - message - } - } -} - -mutation deleteResource($organizationId: ID!, $id: ID!) { - deleteResource(organizationId: $organizationId, id: $id) { - result { - id - name - origin - } - successful - messages { - code - field - message - } - } -} - -mutation exportResource( - $organizationId: ID!, - $id: ID!, - # @genqlient(omitempty: true) - $format: String -) { - exportResource(organizationId: $organizationId, id: $id, format: $format) { - result { - id - name - origin - resourceType { - id - name - icon - connectionOrientation - createdAt - updatedAt - } - payload - rendered - createdAt - updatedAt - } - successful - messages { - code - field - message - } - } -} diff --git a/internal/api/genqlient.yaml b/internal/api/genqlient.yaml deleted file mode 100644 index 817846cb..00000000 --- a/internal/api/genqlient.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# For full documentation see: -# https://github.com/Khan/genqlient/blob/main/docs/genqlient.yaml - -schema: schema.graphql -operations: - - genqlient.graphql -generated: zz_generated.go -package: api -bindings: - JSON: - type: map[string]any - marshaler: github.com/massdriver-cloud/mass/internal/api/scalars.MarshalJSON - unmarshaler: github.com/massdriver-cloud/mass/internal/api/scalars.UnmarshalJSON - Map: - type: map[string]any - marshaler: github.com/massdriver-cloud/mass/internal/api/scalars.MarshalJSON - unmarshaler: github.com/massdriver-cloud/mass/internal/api/scalars.UnmarshalJSON - DateTime: - type: time.Time - VersionConstraint: - type: string - Semver: - type: string - BundleId: - type: string - OciRepoName: - type: string - ReleaseChannel: - type: string - Upload: - type: string - Cursor: - type: github.com/massdriver-cloud/mass/internal/api.Cursor - UUID: - type: string - Conditions: - type: string diff --git a/internal/api/instance.go b/internal/api/instance.go deleted file mode 100644 index 31c59c4d..00000000 --- a/internal/api/instance.go +++ /dev/null @@ -1,191 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -// Instance represents a deployed bundle instance within a Massdriver environment. -type Instance struct { - ID string `json:"id" mapstructure:"id"` - Name string `json:"name" mapstructure:"name"` - Status string `json:"status" mapstructure:"status"` - Version string `json:"version" mapstructure:"version"` - ReleaseStrategy string `json:"releaseStrategy" mapstructure:"releaseStrategy"` - ResolvedVersion string `json:"resolvedVersion,omitempty" mapstructure:"resolvedVersion"` - DeployedVersion string `json:"deployedVersion,omitempty" mapstructure:"deployedVersion"` - AvailableUpgrade string `json:"availableUpgrade,omitempty" mapstructure:"availableUpgrade"` - Params map[string]any `json:"params,omitempty" mapstructure:"params"` - Attributes map[string]string `json:"attributes,omitempty" mapstructure:"attributes"` - Cost CostSummary `json:"cost" mapstructure:"cost"` - CreatedAt time.Time `json:"createdAt,omitzero" mapstructure:"createdAt"` - UpdatedAt time.Time `json:"updatedAt,omitzero" mapstructure:"updatedAt"` - StatePaths []InstanceStatePath `json:"statePaths,omitempty" mapstructure:"statePaths"` - Environment *Environment `json:"environment,omitempty" mapstructure:"environment,omitempty"` - Bundle *Bundle `json:"bundle,omitempty" mapstructure:"bundle,omitempty"` - Component *Component `json:"component,omitempty" mapstructure:"component,omitempty"` -} - -// InstanceStatePath is a Terraform/OpenTofu state path for a deployment step. -type InstanceStatePath struct { - StepName string `json:"stepName" mapstructure:"stepName"` - StateURL string `json:"stateUrl" mapstructure:"stateUrl"` -} - -// InstanceResource pairs a bundle output handle (field) with the resource that was produced on that handle. -type InstanceResource struct { - Field string `json:"field" mapstructure:"field"` - Resource Resource `json:"resource" mapstructure:"resource"` -} - -// InstanceSecret holds metadata for a secret stored on an instance. The value is never returned. -type InstanceSecret struct { - Name string `json:"name" mapstructure:"name"` - CreatedAt time.Time `json:"createdAt,omitzero" mapstructure:"createdAt"` - UpdatedAt time.Time `json:"updatedAt,omitzero" mapstructure:"updatedAt"` -} - -// ParamsJSON returns the instance parameters serialized as a pretty-printed JSON string. -func (inst *Instance) ParamsJSON() (string, error) { - paramsJSON, err := json.MarshalIndent(inst.Params, "", " ") - if err != nil { - return "", fmt.Errorf("failed to marshal params to JSON: %w", err) - } - return string(paramsJSON), nil -} - -// GetInstance retrieves an instance by ID from the Massdriver API. -func GetInstance(ctx context.Context, mdClient *client.Client, id string) (*Instance, error) { - response, err := getInstance(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id) - if err != nil { - return nil, fmt.Errorf("failed to get instance %s: %w", id, err) - } - - return toInstance(response.Instance) -} - -// ListInstanceResources returns every output resource produced by the named instance. -func ListInstanceResources(ctx context.Context, mdClient *client.Client, instanceID string) ([]InstanceResource, error) { - response, err := listInstanceResources(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, instanceID) - if err != nil { - return nil, fmt.Errorf("failed to list instance resources for %s: %w", instanceID, err) - } - - resources := make([]InstanceResource, 0, len(response.Instance.Resources)) - for _, item := range response.Instance.Resources { - ir := InstanceResource{} - if decodeErr := decode(item, &ir); decodeErr != nil { - return nil, fmt.Errorf("failed to decode instance resource: %w", decodeErr) - } - resources = append(resources, ir) - } - - return resources, nil -} - -// ListInstances returns instances, optionally filtered. -func ListInstances(ctx context.Context, mdClient *client.Client, filter *InstancesFilter) ([]Instance, error) { - response, err := listInstances(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, filter, nil, nil) - if err != nil { - return nil, fmt.Errorf("failed to list instances: %w", err) - } - - instances := make([]Instance, 0, len(response.Instances.Items)) - for _, resp := range response.Instances.Items { - inst, instErr := toInstance(resp) - if instErr != nil { - return nil, fmt.Errorf("failed to convert instance: %w", instErr) - } - instances = append(instances, *inst) - } - - return instances, nil -} - -// UpdateInstance updates an instance's version constraint or release strategy. -func UpdateInstance(ctx context.Context, mdClient *client.Client, id string, input UpdateInstanceInput) (*Instance, error) { - response, err := updateInstance(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id, input) - if err != nil { - return nil, err - } - if !response.UpdateInstance.Successful { - messages := response.UpdateInstance.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to update instance:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to update instance") - } - return toInstance(response.UpdateInstance.Result) -} - -// SetInstanceSecret creates or updates a secret on an instance. -func SetInstanceSecret(ctx context.Context, mdClient *client.Client, id string, input SetInstanceSecretInput) (*InstanceSecret, error) { - response, err := setInstanceSecret(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id, input) - if err != nil { - return nil, err - } - if !response.SetInstanceSecret.Successful { - messages := response.SetInstanceSecret.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to set instance secret:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to set instance secret") - } - return toInstanceSecret(response.SetInstanceSecret.Result) -} - -// RemoveInstanceSecret removes a secret from an instance. -func RemoveInstanceSecret(ctx context.Context, mdClient *client.Client, id, name string) (*InstanceSecret, error) { - response, err := removeInstanceSecret(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id, name) - if err != nil { - return nil, err - } - if !response.RemoveInstanceSecret.Successful { - messages := response.RemoveInstanceSecret.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to remove instance secret:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to remove instance secret") - } - return toInstanceSecret(response.RemoveInstanceSecret.Result) -} - -func toInstance(v any) (*Instance, error) { - inst := Instance{} - if err := decode(v, &inst); err != nil { - return nil, fmt.Errorf("failed to decode instance: %w", err) - } - return &inst, nil -} - -func toInstanceSecret(v any) (*InstanceSecret, error) { - secret := InstanceSecret{} - if err := decode(v, &secret); err != nil { - return nil, fmt.Errorf("failed to decode instance secret: %w", err) - } - return &secret, nil -} diff --git a/internal/api/instance_test.go b/internal/api/instance_test.go deleted file mode 100644 index 2e58c7d3..00000000 --- a/internal/api/instance_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package api_test - -import ( - "testing" - - api "github.com/massdriver-cloud/mass/internal/api" - "github.com/massdriver-cloud/mass/internal/gqlmock" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -func TestGetInstance(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "instance": map[string]any{ - "id": "inst-uuid1", - "name": "db", - "status": "PROVISIONED", - "version": "~1.0", - "releaseStrategy": "STABLE", - "environment": map[string]any{ - "id": "env-1", - "name": "production", - }, - "bundle": map[string]any{ - "id": "bundle-1", - "name": "aws-aurora-postgres", - "version": "1.2.3", - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - inst, err := api.GetInstance(t.Context(), &mdClient, "inst-uuid1") - if err != nil { - t.Fatal(err) - } - - if inst.ID != "inst-uuid1" { - t.Errorf("got %s, wanted inst-uuid1", inst.ID) - } - if inst.Status != "PROVISIONED" { - t.Errorf("got %s, wanted PROVISIONED", inst.Status) - } - if inst.Environment == nil || inst.Environment.ID != "env-1" { - t.Errorf("expected environment env-1") - } - if inst.Bundle == nil || inst.Bundle.Name != "aws-aurora-postgres" { - t.Errorf("expected bundle aws-aurora-postgres") - } -} - -func TestListInstances(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "instances": map[string]any{ - "cursor": map[string]any{}, - "items": []map[string]any{ - {"id": "inst-1", "name": "db", "status": "PROVISIONED"}, - {"id": "inst-2", "name": "cache", "status": "INITIALIZED"}, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - instances, err := api.ListInstances(t.Context(), &mdClient, nil) - if err != nil { - t.Fatal(err) - } - - if len(instances) != 2 { - t.Errorf("got %d instances, wanted 2", len(instances)) - } -} - -func TestUpdateInstance(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "updateInstance": map[string]any{ - "result": map[string]any{ - "id": "inst-1", - "name": "db", - "version": "~2.0", - "releaseStrategy": "DEVELOPMENT", - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - inst, err := api.UpdateInstance(t.Context(), &mdClient, "inst-1", api.UpdateInstanceInput{ - Version: "~2.0", - ReleaseStrategy: api.ReleaseStrategyDevelopment, - }) - if err != nil { - t.Fatal(err) - } - - if inst.Version != "~2.0" { - t.Errorf("got %s, wanted ~2.0", inst.Version) - } -} diff --git a/internal/api/main.go b/internal/api/main.go deleted file mode 100644 index 583f832b..00000000 --- a/internal/api/main.go +++ /dev/null @@ -1,3 +0,0 @@ -package api - -//go:generate go run github.com/Khan/genqlient diff --git a/internal/api/oci_repo.go b/internal/api/oci_repo.go deleted file mode 100644 index 9bd4ec4a..00000000 --- a/internal/api/oci_repo.go +++ /dev/null @@ -1,183 +0,0 @@ -package api - -import ( - "context" - "fmt" - "time" - - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -// OciRepo is an OCI repository in your organization's catalog. -type OciRepo struct { - ID string `json:"id"` - Name string `json:"name"` - ArtifactType string `json:"artifactType"` - Description string `json:"description,omitempty"` - Attributes map[string]string `json:"attributes,omitempty"` - CreatedAt time.Time `json:"createdAt,omitzero"` - UpdatedAt time.Time `json:"updatedAt,omitzero"` - ReleaseChannels []OciReleaseChannel `json:"releaseChannels,omitempty"` - Tags []OciRepoTag `json:"tags,omitempty"` -} - -// OciReleaseChannel is a release channel that auto-resolves to the latest matching version. -type OciReleaseChannel struct { - Name string `json:"name"` - Tag string `json:"tag"` -} - -// OciRepoTag is a published version tag in an OCI repository. -type OciRepoTag struct { - Tag string `json:"tag"` - CreatedAt time.Time `json:"createdAt,omitzero"` -} - -// GetOciRepo retrieves an OCI repository by name. -func GetOciRepo(ctx context.Context, mdClient *client.Client, id string) (*OciRepo, error) { - response, err := getOciRepo(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id) - if err != nil { - return nil, fmt.Errorf("failed to get OCI repo %s: %w", id, err) - } - - r := response.OciRepo - repo := OciRepo{ - ID: r.Id, - Name: r.Name, - ArtifactType: r.ArtifactType, - Description: r.Description, - Attributes: anyMapToStringMap(r.Attributes), - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - } - for _, rc := range r.ReleaseChannels.Items { - repo.ReleaseChannels = append(repo.ReleaseChannels, OciReleaseChannel(rc)) - } - for _, tag := range r.Tags.Items { - repo.Tags = append(repo.Tags, OciRepoTag(tag)) - } - - return &repo, nil -} - -// ListOciRepos returns all OCI repositories, optionally filtered and sorted. -// It automatically paginates through all pages. -func ListOciRepos(ctx context.Context, mdClient *client.Client, filter *OciReposFilter, sort *OciReposSort) ([]OciRepo, error) { - var repos []OciRepo - var cursor *Cursor - - for { - response, err := listOciRepos(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, filter, sort, cursor) - if err != nil { - return nil, fmt.Errorf("failed to list OCI repos: %w", err) - } - - for _, r := range response.OciRepos.Items { - repo := OciRepo{ - ID: r.Id, - Name: r.Name, - ArtifactType: r.ArtifactType, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - } - for _, rc := range r.ReleaseChannels.Items { - repo.ReleaseChannels = append(repo.ReleaseChannels, OciReleaseChannel(rc)) - } - repos = append(repos, repo) - } - - next := response.OciRepos.Cursor.Next - if next == "" { - break - } - cursor = &Cursor{Next: next} - } - - return repos, nil -} - -// CreateOciRepo creates a new OCI repository in the organization's catalog. -// `artifactType` selects the kind of artifact the repo will store (today only -// `BUNDLE`; resource-types and provisioners arrive later). -func CreateOciRepo(ctx context.Context, mdClient *client.Client, input CreateOciRepoInput) (*OciRepo, error) { - response, err := createOciRepo(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, input) - if err != nil { - return nil, err - } - if !response.CreateOciRepo.Successful { - messages := make([]string, 0, len(response.CreateOciRepo.Messages)) - for _, m := range response.CreateOciRepo.Messages { - messages = append(messages, m.Message) - } - return nil, mutationError("unable to create OCI repo", messages) - } - r := response.CreateOciRepo.Result - return &OciRepo{ - ID: r.Id, - Name: r.Name, - ArtifactType: r.ArtifactType, - Attributes: anyMapToStringMap(r.Attributes), - }, nil -} - -// UpdateOciRepo updates an OCI repository's mutable metadata (today: attributes). -func UpdateOciRepo(ctx context.Context, mdClient *client.Client, id string, input UpdateOciRepoInput) (*OciRepo, error) { - response, err := updateOciRepo(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id, input) - if err != nil { - return nil, err - } - if !response.UpdateOciRepo.Successful { - messages := make([]string, 0, len(response.UpdateOciRepo.Messages)) - for _, m := range response.UpdateOciRepo.Messages { - messages = append(messages, m.Message) - } - return nil, mutationError("unable to update OCI repo", messages) - } - r := response.UpdateOciRepo.Result - return &OciRepo{ - ID: r.Id, - Name: r.Name, - ArtifactType: r.ArtifactType, - Attributes: anyMapToStringMap(r.Attributes), - }, nil -} - -// DeleteOciRepo removes an OCI repository. Refused by the server if the repo -// has any published versions. -func DeleteOciRepo(ctx context.Context, mdClient *client.Client, id string) (*OciRepo, error) { - response, err := deleteOciRepo(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id) - if err != nil { - return nil, err - } - if !response.DeleteOciRepo.Successful { - messages := make([]string, 0, len(response.DeleteOciRepo.Messages)) - for _, m := range response.DeleteOciRepo.Messages { - messages = append(messages, m.Message) - } - return nil, mutationError("unable to delete OCI repo", messages) - } - r := response.DeleteOciRepo.Result - return &OciRepo{ - ID: r.Id, - Name: r.Name, - ArtifactType: r.ArtifactType, - }, nil -} - -// anyMapToStringMap coerces a `map[string]any` (the shape Map scalar fields -// land in) into the `map[string]string` shape attributes ride on. Non-string -// values are stringified via fmt.Sprintf. -func anyMapToStringMap(m map[string]any) map[string]string { - if len(m) == 0 { - return nil - } - out := make(map[string]string, len(m)) - for k, v := range m { - if s, ok := v.(string); ok { - out[k] = s - continue - } - out[k] = fmt.Sprintf("%v", v) - } - return out -} diff --git a/internal/api/oci_repo_test.go b/internal/api/oci_repo_test.go deleted file mode 100644 index f711423d..00000000 --- a/internal/api/oci_repo_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package api_test - -import ( - "testing" - - api "github.com/massdriver-cloud/mass/internal/api" - "github.com/massdriver-cloud/mass/internal/gqlmock" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -func TestGetOciRepo(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "ociRepo": map[string]any{ - "id": "repo-uuid1", - "name": "aws-aurora-postgres", - "artifactType": "application/vnd.massdriver.bundle.v1+json", - "releaseChannels": map[string]any{ - "items": []map[string]any{ - {"name": "latest", "tag": "1.2.3"}, - {"name": "~1", "tag": "1.2.3"}, - }, - }, - "tags": map[string]any{ - "items": []map[string]any{ - {"tag": "1.2.3"}, - {"tag": "1.1.0"}, - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - repo, err := api.GetOciRepo(t.Context(), &mdClient, "aws-aurora-postgres") - if err != nil { - t.Fatal(err) - } - - if repo.ID != "repo-uuid1" { - t.Errorf("got ID %s, wanted repo-uuid1", repo.ID) - } - if repo.Name != "aws-aurora-postgres" { - t.Errorf("got name %s, wanted aws-aurora-postgres", repo.Name) - } - if repo.ArtifactType != "application/vnd.massdriver.bundle.v1+json" { - t.Errorf("got artifact type %s", repo.ArtifactType) - } - if len(repo.ReleaseChannels) != 2 { - t.Fatalf("got %d release channels, wanted 2", len(repo.ReleaseChannels)) - } - if repo.ReleaseChannels[0].Name != "latest" || repo.ReleaseChannels[0].Tag != "1.2.3" { - t.Errorf("got release channel %+v, wanted latest@1.2.3", repo.ReleaseChannels[0]) - } - if len(repo.Tags) != 2 { - t.Fatalf("got %d tags, wanted 2", len(repo.Tags)) - } - if repo.Tags[0].Tag != "1.2.3" { - t.Errorf("got tag %s, wanted 1.2.3", repo.Tags[0].Tag) - } -} - -func TestListOciRepos(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "ociRepos": map[string]any{ - "cursor": map[string]any{}, - "items": []map[string]any{ - { - "id": "repo-1", - "name": "aws-aurora-postgres", - "artifactType": "application/vnd.massdriver.bundle.v1+json", - }, - { - "id": "repo-2", - "name": "aws-s3", - "artifactType": "application/vnd.massdriver.bundle.v1+json", - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - repos, err := api.ListOciRepos(t.Context(), &mdClient, nil, nil) - if err != nil { - t.Fatal(err) - } - - if len(repos) != 2 { - t.Errorf("got %d repos, wanted 2", len(repos)) - } -} - -func TestListOciReposWithFilter(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "ociRepos": map[string]any{ - "cursor": map[string]any{}, - "items": []map[string]any{ - { - "id": "repo-1", - "name": "aws-aurora-postgres", - "artifactType": "application/vnd.massdriver.bundle.v1+json", - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - filter := api.OciReposFilter{ - Name: &api.OciRepoNameFilter{StartsWith: "aws-"}, - } - repos, err := api.ListOciRepos(t.Context(), &mdClient, &filter, nil) - if err != nil { - t.Fatal(err) - } - - if len(repos) != 1 { - t.Errorf("got %d repos, wanted 1", len(repos)) - } -} diff --git a/internal/api/project.go b/internal/api/project.go deleted file mode 100644 index 40834d8c..00000000 --- a/internal/api/project.go +++ /dev/null @@ -1,146 +0,0 @@ -package api - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -// Project represents a Massdriver project. -type Project struct { - ID string `json:"id" mapstructure:"id"` - Name string `json:"name" mapstructure:"name"` - Description string `json:"description" mapstructure:"description"` - Cost CostSummary `json:"cost" mapstructure:"cost"` - Attributes map[string]string `json:"attributes,omitempty" mapstructure:"attributes"` - Environments []Environment `json:"environments,omitempty" mapstructure:"-"` - Components []Component `json:"components,omitempty" mapstructure:"-"` -} - -// GetProject retrieves a project by ID from the Massdriver API. -func GetProject(ctx context.Context, mdClient *client.Client, id string) (*Project, error) { - response, err := getProject(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id) - if err != nil { - return nil, fmt.Errorf("failed to get project %s: %w", id, err) - } - - return toProject(response.Project) -} - -// ListProjects returns all projects for the configured organization. -func ListProjects(ctx context.Context, mdClient *client.Client) ([]Project, error) { - response, err := listProjects(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID) - if err != nil { - return nil, fmt.Errorf("failed to list projects: %w", err) - } - - records := make([]Project, 0, len(response.Projects.Items)) - for _, resp := range response.Projects.Items { - proj, projErr := toProject(resp) - if projErr != nil { - return nil, fmt.Errorf("failed to convert project: %w", projErr) - } - records = append(records, *proj) - } - - return records, nil -} - -func toProject(p any) (*Project, error) { - proj := Project{} - if err := decode(p, &proj); err != nil { - return nil, fmt.Errorf("failed to decode project: %w", err) - } - - // Unwrap paginated environments and blueprint.components — both come back - // as `{items: [...]}` from the API. - type page[T any] struct { - Items []T `json:"items"` - } - type wrapper struct { - Environments page[Environment] `json:"environments"` - Blueprint *struct { - Components page[Component] `json:"components"` - } `json:"blueprint"` - } - var w wrapper - if err := decode(p, &w); err == nil { - if len(w.Environments.Items) > 0 { - proj.Environments = w.Environments.Items - } - if w.Blueprint != nil && len(w.Blueprint.Components.Items) > 0 { - proj.Components = w.Blueprint.Components.Items - } - } - - return &proj, nil -} - -// CreateProject creates a new project in the Massdriver API. -func CreateProject(ctx context.Context, mdClient *client.Client, input CreateProjectInput) (*Project, error) { - response, err := createProject(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, input) - if err != nil { - return nil, err - } - if !response.CreateProject.Successful { - messages := response.CreateProject.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to create project:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to create project") - } - return toProject(response.CreateProject.Result) -} - -// UpdateProject updates a project in the Massdriver API. -func UpdateProject(ctx context.Context, mdClient *client.Client, id string, input UpdateProjectInput) (*Project, error) { - response, err := updateProject(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id, input) - if err != nil { - return nil, err - } - if !response.UpdateProject.Successful { - messages := response.UpdateProject.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to update project:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to update project") - } - return toProject(response.UpdateProject.Result) -} - -// DeleteProject removes a project by ID from the Massdriver API. -func DeleteProject(ctx context.Context, mdClient *client.Client, id string) (*Project, error) { - response, err := deleteProject(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id) - if err != nil { - return nil, err - } - if !response.DeleteProject.Successful { - messages := response.DeleteProject.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to delete project:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to delete project") - } - return toProject(response.DeleteProject.Result) -} diff --git a/internal/api/project_test.go b/internal/api/project_test.go deleted file mode 100644 index a1c1ec3b..00000000 --- a/internal/api/project_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package api_test - -import ( - "testing" - - api "github.com/massdriver-cloud/mass/internal/api" - "github.com/massdriver-cloud/mass/internal/gqlmock" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -func TestGetProject(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "project": map[string]any{ - "id": "proj-uuid1", - "name": "My Project", - "description": "A test project", - }, - }, - }) - mdClient := client.Client{ - GQLv2: gqlClient, - } - - project, err := api.GetProject(t.Context(), &mdClient, "proj-uuid1") - if err != nil { - t.Fatal(err) - } - - if project.ID != "proj-uuid1" { - t.Errorf("got %s, wanted proj-uuid1", project.ID) - } - if project.Name != "My Project" { - t.Errorf("got %s, wanted My Project", project.Name) - } -} - -func TestListProjects(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "projects": map[string]any{ - "cursor": map[string]any{}, - "items": []map[string]any{ - { - "id": "uuid1", - "name": "project1", - }, - { - "id": "uuid2", - "name": "project2", - }, - }, - }, - }, - }) - mdClient := client.Client{ - GQLv2: gqlClient, - } - - projects, err := api.ListProjects(t.Context(), &mdClient) - if err != nil { - t.Fatal(err) - } - - if len(projects) != 2 { - t.Errorf("got %d projects, wanted 2", len(projects)) - } -} - -func TestCreateProject(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "createProject": map[string]any{ - "result": map[string]any{ - "id": "new-proj", - "name": "New Project", - "description": "A new project", - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{ - GQLv2: gqlClient, - } - - project, err := api.CreateProject(t.Context(), &mdClient, api.CreateProjectInput{ - Id: "new-proj", - Name: "New Project", - Description: "A new project", - }) - if err != nil { - t.Fatal(err) - } - - if project.ID != "new-proj" { - t.Errorf("got %s, wanted new-proj", project.ID) - } - if project.Name != "New Project" { - t.Errorf("got %s, wanted New Project", project.Name) - } -} - -func TestCreateProjectFailure(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "createProject": map[string]any{ - "result": nil, - "successful": false, - "messages": []map[string]any{ - { - "code": "required", - "field": "name", - "message": "name is required", - }, - }, - }, - }, - }) - mdClient := client.Client{ - GQLv2: gqlClient, - } - - _, err := api.CreateProject(t.Context(), &mdClient, api.CreateProjectInput{}) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestDeleteProject(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "deleteProject": map[string]any{ - "result": map[string]any{ - "id": "proj-1", - "name": "Deleted Project", - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{ - GQLv2: gqlClient, - } - - project, err := api.DeleteProject(t.Context(), &mdClient, "proj-1") - if err != nil { - t.Fatal(err) - } - - if project.ID != "proj-1" { - t.Errorf("got %s, wanted proj-1", project.ID) - } -} diff --git a/internal/api/resource.go b/internal/api/resource.go deleted file mode 100644 index d9bd3d70..00000000 --- a/internal/api/resource.go +++ /dev/null @@ -1,183 +0,0 @@ -package api - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -// Resource is an infrastructure artifact such as cloud credentials, a database connection string, -// or any other output produced by (or imported into) Massdriver. -// Replaces the v0 concept of "artifact". -type Resource struct { - ID string `json:"id" mapstructure:"id"` - Name string `json:"name" mapstructure:"name"` - Origin string `json:"origin" mapstructure:"origin"` - ResourceType *ResourceType `json:"resourceType,omitempty" mapstructure:"resourceType,omitempty"` - Field string `json:"field,omitempty" mapstructure:"field"` - Instance *Instance `json:"instance,omitempty" mapstructure:"instance,omitempty"` - Formats []string `json:"formats,omitempty" mapstructure:"formats"` - Payload map[string]any `json:"payload,omitempty" mapstructure:"payload,omitempty"` - CreatedAt time.Time `json:"createdAt,omitzero" mapstructure:"createdAt"` - UpdatedAt time.Time `json:"updatedAt,omitzero" mapstructure:"updatedAt"` -} - -// GetResource retrieves a resource by ID. -func GetResource(ctx context.Context, mdClient *client.Client, id string) (*Resource, error) { - response, err := getResource(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id) - if err != nil { - return nil, fmt.Errorf("failed to get resource %s: %w", id, err) - } - return toResource(response.Resource) -} - -// ListResources returns resources, optionally filtered. -func ListResources(ctx context.Context, mdClient *client.Client, filter *ResourcesFilter) ([]Resource, error) { - var resources []Resource - var cursor *Cursor - - for { - response, err := listResources(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, filter, nil, cursor) - if err != nil { - return nil, fmt.Errorf("failed to list resources: %w", err) - } - - for _, resp := range response.Resources.Items { - r, rErr := toResource(resp) - if rErr != nil { - return nil, fmt.Errorf("failed to convert resource: %w", rErr) - } - resources = append(resources, *r) - } - - next := response.Resources.Cursor.Next - if next == "" { - break - } - cursor = &Cursor{Next: next} - } - - return resources, nil -} - -// CreateResource imports a new resource of the given type. -func CreateResource(ctx context.Context, mdClient *client.Client, resourceTypeID string, input CreateResourceInput) (*Resource, error) { - response, err := createResource(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, resourceTypeID, input) - if err != nil { - return nil, err - } - if !response.CreateResource.Successful { - messages := response.CreateResource.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to create resource:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to create resource") - } - return toResource(response.CreateResource.Result) -} - -// UpdateResource updates an existing resource. -func UpdateResource(ctx context.Context, mdClient *client.Client, id string, input UpdateResourceInput) (*Resource, error) { - response, err := updateResource(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id, input) - if err != nil { - return nil, err - } - if !response.UpdateResource.Successful { - messages := response.UpdateResource.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to update resource:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to update resource") - } - return toResource(response.UpdateResource.Result) -} - -// ResourceWithSensitiveValues is a resource whose `$md.sensitive` payload fields are unmasked. -// Returned by ExportResource; requesting it is recorded in the audit log. -type ResourceWithSensitiveValues struct { - ID string `json:"id" mapstructure:"id"` - Name string `json:"name" mapstructure:"name"` - Origin string `json:"origin" mapstructure:"origin"` - ResourceType *ResourceType `json:"resourceType,omitempty" mapstructure:"resourceType,omitempty"` - Payload map[string]any `json:"payload,omitempty" mapstructure:"payload,omitempty"` - Rendered string `json:"rendered" mapstructure:"rendered"` - CreatedAt time.Time `json:"createdAt,omitzero" mapstructure:"createdAt"` - UpdatedAt time.Time `json:"updatedAt,omitzero" mapstructure:"updatedAt"` -} - -// ExportResource returns a resource with sensitive payload fields unmasked, along with a `rendered` -// string in the requested format. An empty format defaults to the API's default (json). -func ExportResource(ctx context.Context, mdClient *client.Client, id, format string) (*ResourceWithSensitiveValues, error) { - response, err := exportResource(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id, format) - if err != nil { - return nil, err - } - if !response.ExportResource.Successful { - messages := response.ExportResource.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to export resource:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to export resource") - } - return toResourceWithSensitiveValues(response.ExportResource.Result) -} - -// DeleteResource deletes an imported resource by ID. -func DeleteResource(ctx context.Context, mdClient *client.Client, id string) (*Resource, error) { - response, err := deleteResource(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id) - if err != nil { - return nil, err - } - if !response.DeleteResource.Successful { - messages := response.DeleteResource.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to delete resource:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to delete resource") - } - return toResource(response.DeleteResource.Result) -} - -func toResource(v any) (*Resource, error) { - r := Resource{} - if err := decode(v, &r); err != nil { - return nil, fmt.Errorf("failed to decode resource: %w", err) - } - return &r, nil -} - -func toResourceWithSensitiveValues(v any) (*ResourceWithSensitiveValues, error) { - r := ResourceWithSensitiveValues{} - if err := decode(v, &r); err != nil { - return nil, fmt.Errorf("failed to decode resource: %w", err) - } - return &r, nil -} diff --git a/internal/api/resource_test.go b/internal/api/resource_test.go deleted file mode 100644 index 62eee364..00000000 --- a/internal/api/resource_test.go +++ /dev/null @@ -1,324 +0,0 @@ -package api_test - -import ( - "testing" - - api "github.com/massdriver-cloud/mass/internal/api" - "github.com/massdriver-cloud/mass/internal/gqlmock" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -func TestGetResource(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "resource": map[string]any{ - "id": "res-uuid1", - "name": "my-vpc", - "origin": "PROVISIONED", - "resourceType": map[string]any{ - "id": "aws-vpc", - "name": "AWS VPC", - }, - "field": "network", - "instance": map[string]any{ - "id": "ecomm-prod-vpc", - "name": "vpc", - }, - "formats": []string{"json", "yaml"}, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - r, err := api.GetResource(t.Context(), &mdClient, "res-uuid1") - if err != nil { - t.Fatal(err) - } - - if r.ID != "res-uuid1" { - t.Errorf("got ID %s, wanted res-uuid1", r.ID) - } - if r.Name != "my-vpc" { - t.Errorf("got name %s, wanted my-vpc", r.Name) - } - if r.Origin != "PROVISIONED" { - t.Errorf("got origin %s, wanted PROVISIONED", r.Origin) - } - if r.ResourceType == nil || r.ResourceType.ID != "aws-vpc" { - t.Errorf("expected resource type aws-vpc") - } - if r.Field != "network" { - t.Errorf("got field %s, wanted network", r.Field) - } - if r.Instance == nil || r.Instance.ID != "ecomm-prod-vpc" { - t.Errorf("expected instance ecomm-prod-vpc") - } - if len(r.Formats) != 2 || r.Formats[0] != "json" || r.Formats[1] != "yaml" { - t.Errorf("got formats %v, wanted [json yaml]", r.Formats) - } -} - -func TestListResources(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "resources": map[string]any{ - "cursor": map[string]any{}, - "items": []map[string]any{ - { - "id": "res-1", - "name": "my-vpc", - "origin": "IMPORTED", - "resourceType": map[string]any{ - "id": "aws-vpc", - "name": "AWS VPC", - }, - }, - { - "id": "res-2", - "name": "db-creds", - "origin": "PROVISIONED", - "resourceType": map[string]any{ - "id": "aws-rds-auth", - "name": "AWS RDS Auth", - }, - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - resources, err := api.ListResources(t.Context(), &mdClient, nil) - if err != nil { - t.Fatal(err) - } - - if len(resources) != 2 { - t.Errorf("got %d resources, wanted 2", len(resources)) - } -} - -func TestListResourcesWithFilter(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "resources": map[string]any{ - "cursor": map[string]any{}, - "items": []map[string]any{ - { - "id": "res-1", - "name": "my-vpc", - "origin": "IMPORTED", - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - filter := api.ResourcesFilter{ - Origin: &api.ResourceOriginFilter{Eq: "IMPORTED"}, - } - resources, err := api.ListResources(t.Context(), &mdClient, &filter) - if err != nil { - t.Fatal(err) - } - - if len(resources) != 1 { - t.Errorf("got %d resources, wanted 1", len(resources)) - } -} - -func TestCreateResource(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "createResource": map[string]any{ - "result": map[string]any{ - "id": "res-new", - "name": "CI/CD Role", - "origin": "IMPORTED", - "resourceType": map[string]any{ - "id": "aws-iam-role", - "name": "AWS IAM Role", - }, - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - r, err := api.CreateResource(t.Context(), &mdClient, "aws-iam-role", api.CreateResourceInput{ - Name: "CI/CD Role", - }) - if err != nil { - t.Fatal(err) - } - - if r.ID != "res-new" { - t.Errorf("got ID %s, wanted res-new", r.ID) - } - if r.Name != "CI/CD Role" { - t.Errorf("got name %s, wanted CI/CD Role", r.Name) - } -} - -func TestCreateResourceFailure(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "createResource": map[string]any{ - "result": nil, - "successful": false, - "messages": []map[string]any{ - { - "code": "validation", - "field": "name", - "message": "name is required", - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - _, err := api.CreateResource(t.Context(), &mdClient, "aws-iam-role", api.CreateResourceInput{}) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestUpdateResource(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "updateResource": map[string]any{ - "result": map[string]any{ - "id": "res-1", - "name": "Updated Name", - "origin": "IMPORTED", - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - r, err := api.UpdateResource(t.Context(), &mdClient, "res-1", api.UpdateResourceInput{ - Name: "Updated Name", - }) - if err != nil { - t.Fatal(err) - } - - if r.Name != "Updated Name" { - t.Errorf("got name %s, wanted Updated Name", r.Name) - } -} - -func TestDeleteResource(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "deleteResource": map[string]any{ - "result": map[string]any{ - "id": "res-1", - "name": "my-vpc", - "origin": "IMPORTED", - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - r, err := api.DeleteResource(t.Context(), &mdClient, "res-1") - if err != nil { - t.Fatal(err) - } - - if r.ID != "res-1" { - t.Errorf("got ID %s, wanted res-1", r.ID) - } -} - -func TestExportResource(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "exportResource": map[string]any{ - "result": map[string]any{ - "id": "res-1", - "name": "db-creds", - "origin": "PROVISIONED", - "resourceType": map[string]any{ - "id": "aws-rds-auth", - "name": "AWS RDS Auth", - }, - "payload": map[string]any{"password": "s3cret"}, - "rendered": `{"password":"s3cret"}`, - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - r, err := api.ExportResource(t.Context(), &mdClient, "res-1", "json") - if err != nil { - t.Fatal(err) - } - - if r.ID != "res-1" { - t.Errorf("got ID %s, wanted res-1", r.ID) - } - if r.Payload["password"] != "s3cret" { - t.Errorf("got payload password %v, wanted s3cret", r.Payload["password"]) - } - if r.Rendered != `{"password":"s3cret"}` { - t.Errorf("got rendered %s, wanted {\"password\":\"s3cret\"}", r.Rendered) - } -} - -func TestExportResourceFailure(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "exportResource": map[string]any{ - "result": nil, - "successful": false, - "messages": []map[string]any{ - { - "code": "forbidden", - "field": "id", - "message": "caller cannot export this resource", - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - _, err := api.ExportResource(t.Context(), &mdClient, "res-1", "") - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestDeleteResourceFailure(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "deleteResource": map[string]any{ - "result": nil, - "successful": false, - "messages": []map[string]any{ - { - "code": "conflict", - "field": "id", - "message": "resource is referenced by active connections", - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - _, err := api.DeleteResource(t.Context(), &mdClient, "res-1") - if err == nil { - t.Fatal("expected error, got nil") - } -} diff --git a/internal/api/resource_type.go b/internal/api/resource_type.go index fb5faf9c..c18d27ba 100644 --- a/internal/api/resource_type.go +++ b/internal/api/resource_type.go @@ -2,118 +2,199 @@ package api import ( "context" - "errors" + "encoding/json" "fmt" - "strings" "time" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" + "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 defines a category of resource (e.g., "aws-iam-role", "kubernetes-cluster"). -// Replaces the v0 concept of "artifact definition". +// 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" mapstructure:"id"` - Name string `json:"name" mapstructure:"name"` - Icon string `json:"icon,omitempty" mapstructure:"icon"` - ConnectionOrientation string `json:"connectionOrientation" mapstructure:"connectionOrientation"` - Schema map[string]any `json:"schema,omitempty" mapstructure:"schema"` - CreatedAt time.Time `json:"createdAt,omitzero" mapstructure:"createdAt"` - UpdatedAt time.Time `json:"updatedAt,omitzero" mapstructure:"updatedAt"` + 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"` } -// GetResourceType retrieves a resource type by ID. -func GetResourceType(ctx context.Context, mdClient *client.Client, id string) (*ResourceType, error) { - response, err := getResourceType(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id) - if err != nil { - return nil, fmt.Errorf("failed to get resource type %s: %w", id, err) - } - return toResourceType(response.ResourceType) +// 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"` } -// ListResourceTypes returns resource types, optionally filtered, following pagination. -func ListResourceTypes(ctx context.Context, mdClient *client.Client, filter *ResourceTypesFilter) ([]ResourceType, error) { - var resourceTypes []ResourceType - var cursor *Cursor +const getResourceTypeQuery = `query getResourceType($organizationId: ID!, $id: ID!) { + resourceType(organizationId: $organizationId, id: $id) { + id + name + icon + connectionOrientation + schema + createdAt + updatedAt + } +}` - for { - response, err := listResourceTypes(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, filter, nil, cursor) - if err != nil { - return nil, fmt.Errorf("failed to list resource types: %w", err) - } +const listResourceTypesQuery = `query listResourceTypes($organizationId: ID!) { + resourceTypes(organizationId: $organizationId) { + items { + id + name + icon + connectionOrientation + createdAt + updatedAt + } + } +}` - for _, resp := range response.ResourceTypes.Items { - rt, rtErr := toResourceType(resp) - if rtErr != nil { - return nil, fmt.Errorf("failed to convert resource type: %w", rtErr) - } - resourceTypes = append(resourceTypes, *rt) - } +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 + } + } +}` - next := response.ResourceTypes.Cursor.Next - if next == "" { - break - } - cursor = &Cursor{Next: next} - } +const deleteResourceTypeMutation = `mutation deleteResourceType($organizationId: ID!, $id: ID!) { + deleteResourceType(organizationId: $organizationId, id: $id) { + result { + id + name + } + successful + messages { + code + field + message + } + } +}` - return resourceTypes, nil +// 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 } -// PublishResourceType upserts a resource type from a JSON Schema document. If an existing -// resource type has the same identifier (from `$md.name` in the schema), its schema is replaced. -// -// Deprecated: transitional shim from V0 `publishArtifactDefinition`. Prefer the OCI-native flow. -func PublishResourceType(ctx context.Context, mdClient *client.Client, input PublishResourceTypeInput) (*ResourceType, error) { - response, err := publishResourceType(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, input) - if err != nil { - return nil, err - } - if !response.PublishResourceType.Successful { - messages := response.PublishResourceType.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to publish resource type:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to publish resource type") - } - return toResourceType(response.PublishResourceType.Result) +// ListResourceTypes fetches all resource types in the configured organization. +// The legacy CLI supported a filter argument; the few callsites that survive +// the v2 migration only need the unfiltered list. +func ListResourceTypes(ctx context.Context, mdClient *massdriver.Client) ([]ResourceType, error) { + cfg := mdClient.Config() + var resp struct { + ResourceTypes struct { + Items []ResourceType `json:"items"` + } `json:"resourceTypes"` + } + req := &graphql.Request{ + OpName: "listResourceTypes", + Query: listResourceTypesQuery, + Variables: map[string]any{ + "organizationId": cfg.OrganizationID, + }, + } + if err := gqlClient(mdClient).MakeRequest(ctx, req, &graphql.Response{Data: &resp}); err != nil { + return nil, fmt.Errorf("list resource types: %w", err) + } + return resp.ResourceTypes.Items, nil } -// DeleteResourceType deletes a resource type by ID. Fails if the type is still referenced by -// bundles or existing resources. -// -// Deprecated: transitional shim from V0 `deleteArtifactDefinition`. Prefer the OCI-native flow. -func DeleteResourceType(ctx context.Context, mdClient *client.Client, id string) (*ResourceType, error) { - response, err := deleteResourceType(ctx, mdClient.GQLv2, mdClient.Config.OrganizationID, id) +// 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, err - } - if !response.DeleteResourceType.Successful { - messages := response.DeleteResourceType.GetMessages() - if len(messages) > 0 { - var sb strings.Builder - sb.WriteString("unable to delete resource type:") - for _, msg := range messages { - sb.WriteString("\n - ") - sb.WriteString(msg.Message) - } - return nil, errors.New(sb.String()) - } - return nil, errors.New("unable to delete resource type") - } - return toResourceType(response.DeleteResourceType.Result) + 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 } -func toResourceType(v any) (*ResourceType, error) { - rt := ResourceType{} - if err := decode(v, &rt); err != nil { - return nil, fmt.Errorf("failed to decode resource type: %w", err) +// 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 &rt, nil + return resp.DeleteResourceType.Result, nil } diff --git a/internal/api/resource_type_test.go b/internal/api/resource_type_test.go deleted file mode 100644 index a163993b..00000000 --- a/internal/api/resource_type_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package api_test - -import ( - "testing" - - api "github.com/massdriver-cloud/mass/internal/api" - "github.com/massdriver-cloud/mass/internal/gqlmock" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" -) - -func TestGetResourceType(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "resourceType": map[string]any{ - "id": "aws-iam-role", - "name": "AWS IAM Role", - "icon": "https://example.com/iam.png", - "connectionOrientation": "LINK", - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - rt, err := api.GetResourceType(t.Context(), &mdClient, "aws-iam-role") - if err != nil { - t.Fatal(err) - } - - if rt.ID != "aws-iam-role" { - t.Errorf("got ID %s, wanted aws-iam-role", rt.ID) - } - if rt.Name != "AWS IAM Role" { - t.Errorf("got name %s, wanted AWS IAM Role", rt.Name) - } - if rt.Icon != "https://example.com/iam.png" { - t.Errorf("got icon %s, wanted https://example.com/iam.png", rt.Icon) - } - if rt.ConnectionOrientation != "LINK" { - t.Errorf("got connectionOrientation %s, wanted LINK", rt.ConnectionOrientation) - } -} - -func TestListResourceTypes(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "resourceTypes": map[string]any{ - "cursor": map[string]any{}, - "items": []map[string]any{ - { - "id": "aws-iam-role", - "name": "AWS IAM Role", - "connectionOrientation": "LINK", - }, - { - "id": "kubernetes-cluster", - "name": "Kubernetes Cluster", - "connectionOrientation": "ENVIRONMENT_DEFAULT", - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - rts, err := api.ListResourceTypes(t.Context(), &mdClient, nil) - if err != nil { - t.Fatal(err) - } - - if len(rts) != 2 { - t.Errorf("got %d resource types, wanted 2", len(rts)) - } -} - -func TestPublishResourceType(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "publishResourceType": map[string]any{ - "result": map[string]any{ - "id": "aws-iam-role", - "name": "AWS IAM Role", - "connectionOrientation": "LINK", - "schema": map[string]any{ - "$md": map[string]any{"name": "aws-iam-role"}, - "type": "object", - }, - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - rt, err := api.PublishResourceType(t.Context(), &mdClient, api.PublishResourceTypeInput{ - Schema: map[string]any{ - "$md": map[string]any{"name": "aws-iam-role"}, - "type": "object", - }, - }) - if err != nil { - t.Fatal(err) - } - - if rt.ID != "aws-iam-role" { - t.Errorf("got ID %s, wanted aws-iam-role", rt.ID) - } -} - -func TestPublishResourceTypeFailure(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "publishResourceType": map[string]any{ - "result": nil, - "successful": false, - "messages": []map[string]any{ - { - "code": "validation", - "field": "schema", - "message": "schema is missing $md.name", - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - _, err := api.PublishResourceType(t.Context(), &mdClient, api.PublishResourceTypeInput{}) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestDeleteResourceType(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "deleteResourceType": map[string]any{ - "result": map[string]any{ - "id": "aws-iam-role", - "name": "AWS IAM Role", - }, - "successful": true, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - rt, err := api.DeleteResourceType(t.Context(), &mdClient, "aws-iam-role") - if err != nil { - t.Fatal(err) - } - - if rt.ID != "aws-iam-role" { - t.Errorf("got ID %s, wanted aws-iam-role", rt.ID) - } -} - -func TestDeleteResourceTypeFailure(t *testing.T) { - gqlClient := gqlmock.NewClientWithSingleJSONResponse(map[string]any{ - "data": map[string]any{ - "deleteResourceType": map[string]any{ - "result": nil, - "successful": false, - "messages": []map[string]any{ - { - "code": "conflict", - "field": "id", - "message": "resource type is still in use", - }, - }, - }, - }, - }) - mdClient := client.Client{GQLv2: gqlClient} - - _, err := api.DeleteResourceType(t.Context(), &mdClient, "aws-iam-role") - if err == nil { - t.Fatal("expected error, got nil") - } -} diff --git a/internal/api/scalars/cursor.go b/internal/api/scalars/cursor.go deleted file mode 100644 index 37c7dcaf..00000000 --- a/internal/api/scalars/cursor.go +++ /dev/null @@ -1,10 +0,0 @@ -// Package scalars provides custom GraphQL scalar types. -package scalars - -// Cursor represents pagination cursor with omitempty on all fields -// to avoid sending empty strings to the server -type Cursor struct { - Limit int `json:"limit,omitempty"` - Next string `json:"next,omitempty"` - Previous string `json:"previous,omitempty"` -} diff --git a/internal/api/scalars/json.go b/internal/api/scalars/json.go deleted file mode 100644 index 0493b02e..00000000 --- a/internal/api/scalars/json.go +++ /dev/null @@ -1,52 +0,0 @@ -package scalars - -import ( - "encoding/json" - "reflect" -) - -// MarshalJSON double-encodes a value into an escaped JSON string for -// transport over Massdriver's `JSON`/`Map` GraphQL scalars. -// -// Empty/nil maps return a nil byte slice. The wrapping json.RawMessage then -// either gets elided (when the field has `omitempty`) or marshals as the bare -// `null` literal (when it doesn't) — both shapes the server accepts. An empty -// non-nil slice would error in encoding/json with "unexpected end of JSON -// input" on no-omitempty fields, so nil is the safe choice. -func MarshalJSON(v any) ([]byte, error) { - if isEmpty(v) { - return nil, nil - } - bytes, err := json.Marshal(v) - if err != nil { - return nil, err - } - return json.Marshal(string(bytes)) -} - -// UnmarshalJSON unmarshals raw JSON bytes into the provided map. -func UnmarshalJSON(data []byte, v *map[string]any) error { - return json.Unmarshal(data, v) -} - -// isEmpty reports whether v should be treated as absent — only nil values and -// nil maps/slices qualify. An explicitly empty map (e.g. `map[string]any{}`) -// is *not* empty: it is a valid `Map` value (`{}`) and required fields like -// `CreateDeploymentInput.params` must serialize it that way. -func isEmpty(v any) bool { - if v == nil { - return true - } - rv := reflect.ValueOf(v) - for rv.Kind() == reflect.Ptr { - if rv.IsNil() { - return true - } - rv = rv.Elem() - } - switch rv.Kind() { //nolint:exhaustive // only nil-able container kinds need the IsNil check - case reflect.Map, reflect.Slice: - return rv.IsNil() - } - return false -} diff --git a/internal/api/scalars/json_test.go b/internal/api/scalars/json_test.go deleted file mode 100644 index f25ab02e..00000000 --- a/internal/api/scalars/json_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package scalars_test - -import ( - "reflect" - "testing" - - "github.com/massdriver-cloud/mass/internal/api/scalars" -) - -func TestMarshalJSON(t *testing.T) { - data := map[string]any{"foo": "bar"} - got, _ := scalars.MarshalJSON(data) - - want := `"{\"foo\":\"bar\"}"` - - if string(got) != want { - t.Errorf("got %s, wanted %s", got, want) - } -} - -func TestUnmarshalJSON(t *testing.T) { - want := map[string]any{"foo": "bar"} - - data := []byte(`{"foo": "bar"}`) - got := map[string]any{} - - if err := scalars.UnmarshalJSON(data, &got); err != nil { - t.Errorf("unexpected error: %v", err) - } - - if !reflect.DeepEqual(got, want) { - t.Errorf("got %v, wanted %v", got, want) - } -} diff --git a/internal/api/schema.graphql b/internal/api/schema.graphql deleted file mode 100644 index 71aa2237..00000000 --- a/internal/api/schema.graphql +++ /dev/null @@ -1,8941 +0,0 @@ -schema { - mutation: RootMutationType - subscription: RootSubscriptionType - query: RootQueryType -} - -"Links a mutation to its JSON Schema and UI Schema for form generation" -directive @inputs( - "URL to the UI Schema" - ui: String! - - "URL to the JSON Schema" - schema: String! - - "Schema name matching the mutation (e.g., createProject)" - name: String! -) on FIELD_DEFINITION - -type RootQueryType { - """ - List all projects you have access to in the organization. - - Returns a cursor-paginated list. Use `sort` to control ordering (defaults to name ascending). - - ```graphql - query { - projects(organizationId: "my-org") { - items { id name description attributes } - cursor { next } - } - } - ``` - """ - projects( - "Your organization's unique identifier." - organizationId: ID! - - "How to sort results. Defaults to name ascending." - sort: ProjectsSort - - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): ProjectsPage - - "Fetch a single project by its identifier." - project( - "Your organization's unique identifier." - organizationId: ID! - - "The project's unique identifier." - id: ID! - ): Project - - """ - List all environments you have access to across all projects. - - Returns a cursor-paginated list. Use `filter` to narrow by project or environment ID, - and `sort` to control ordering (defaults to name ascending). - - ```graphql - query { - environments(organizationId: "my-org", filter: { projectId: { eq: "my-project" } }) { - items { id name project { id } } - cursor { next } - } - } - ``` - """ - environments( - "Your organization's unique identifier." - organizationId: ID! - - "Optional filters to narrow results." - filter: EnvironmentsFilter - - "How to sort results. Defaults to name ascending." - sort: EnvironmentsSort - - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): EnvironmentsPage - - "Fetch a single environment by its identifier." - environment( - "Your organization's unique identifier." - organizationId: ID! - - "The environment's unique identifier." - id: ID! - ): Environment - - """ - Compare two environments in the same project, instance-by-instance. - - Instances are paired by component. For each component, the result reports - the resolved version on each side and a flat, leaf-level diff of the - configured params. Environment-level attributes and default wiring are not part - of the comparison. - - Both environments must belong to the same project; passing environments - from different projects returns a `FORBIDDEN` error because components - don't cross project boundaries. - - ```graphql - query { - compareEnvironments(organizationId: "my-org", sourceId: "staging", targetId: "prod") { - source { id } target { id } - instances { - component { id name } - source { id } target { id } - version { source target equal } - params { path equal source { value } target { value } } - equal - } - } - } - ``` - """ - compareEnvironments( - "Your organization's unique identifier." - organizationId: ID! - - "The source environment's identifier." - sourceId: ID! - - "The target environment's identifier." - targetId: ID! - ): EnvironmentComparison - - """ - List all instances you have access to. - - Returns a paginated list of deployed infrastructure across all projects - and environments visible to the current user. Use filters to narrow by - project, environment, status, bundle, or configuration parameters. - - ```graphql - query { - instances( - organizationId: "my-org" - filter: { status: { eq: PROVISIONED } } - sort: { field: name, order: ASC } - ) { - items { - id - name - status - resolvedVersion - environment { id } - } - cursor { after } - } - } - ``` - """ - instances( - "Your organization's unique identifier." - organizationId: ID! - - "Narrow results by project, environment, status, bundle, or params." - filter: InstancesFilter - - "Sort field and direction. Defaults to `name` ascending." - sort: InstancesSort - - "Pagination cursor returned by a previous page." - cursor: Cursor - ): InstancesPage - - "Fetch a single instance by its ID. Returns null with a `NOT_FOUND` error if the instance does not exist." - instance( - "Your organization's unique identifier." - organizationId: ID! - - "The instance ID to look up." - id: ID! - ): Instance - - """ - Discover the configuration fields available across your instances. - - Introspects the bundle schemas of all accessible instances and returns the - unique, filterable parameter fields. Use the results to build dynamic search - filters or infrastructure dashboards. - - For example, if your database instances expose `database.instance_type` and - your Kubernetes instances expose `cluster.node_count`, both will appear here. - You can then pass those fields to the `paramDimension` filter on the - `instances` query. - - ```graphql - query { - paramDimensions(organizationId: "my-org") { - items { field label type } - cursor { after } - } - } - ``` - """ - paramDimensions( - "Your organization's unique identifier." - organizationId: ID! - - "Scope which instances' schemas to introspect." - filter: ParamDimensionsFilter - - "Sort field and direction. Defaults to `field` ascending." - sort: ParamDimensionsSort - - "Pagination cursor returned by a previous page." - cursor: Cursor - ): ParamDimensionsPage - - """ - Fetch a single component by its ID. - - Returns null with a `NOT_FOUND` error if the component does not exist or - is not visible to the caller. - - ```graphql - query { - component(organizationId: "my-org", id: "my-project-database") { - id - name - instances { - items { id environment { id } } - } - } - } - ``` - """ - component( - "Your organization's unique identifier." - organizationId: ID! - - "The component ID to look up (e.g., `myproject-database`)." - id: ID! - ): Component - - """ - List deployments across all projects and environments you have access to. - - Returns a cursor-paginated list, sorted by newest first by default. Use `filter` - to narrow results by instance, status, or action type. - - ```graphql - query { - deployments(organizationId: "my-org", filter: { status: { eq: RUNNING } }) { - items { - id - status - action - version - elapsedTime - deployedBy - instance { id } - } - cursor { next } - } - } - ``` - """ - deployments( - "Your organization's unique identifier." - organizationId: ID! - - "Optional filters to narrow the results." - filter: DeploymentsFilter - - "Sort order for results. Defaults to most recently active first." - sort: DeploymentsSort - - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): DeploymentsPage - - """ - Fetch a single deployment by its unique identifier. - - Returns the full deployment record including status, timing, and the associated instance. - """ - deployment( - "Your organization's unique identifier." - organizationId: ID! - - "The deployment's unique identifier." - id: UUID! - ): Deployment - - """ - Compare two deployments side-by-side. - - Returns the bundle version on each side and a flat, leaf-level diff of - the snapshotted params. Useful for auditing what a deploy changed, or - for contrasting deploys from different points in time. - - Both deployments must belong to the requesting organization. There is no - requirement that they target the same instance — callers can pass any - two deployments they have access to, though comparisons across unrelated - instances will naturally show every leaf as "only on one side". - - ```graphql - query { - compareDeployments(organizationId: "my-org", sourceId: "", targetId: "") { - source { id status version } - target { id status version } - version { source target equal } - params { path source { value } target { value } equal } - } - } - ``` - """ - compareDeployments( - "Your organization's unique identifier." - organizationId: ID! - - "The source deployment's unique identifier." - sourceId: UUID! - - "The target deployment's unique identifier." - targetId: UUID! - ): DeploymentComparison - - """ - List published bundle versions in your organization's catalog. - - Returns a paginated list of all bundle releases across all OCI repositories. - Each item is a specific version (e.g., `aws-aurora-postgres@1.2.3`), not just - the repository. - - Use `filter` to narrow results by repository name, resource type produced, - dependency type consumed, or full-text search. When `search` is active, results - are ranked by relevance unless an explicit `sort` is provided. - - ```graphql - query { - bundles(organizationId: "your-org-id", filter: { resourceType: { eq: "aws-iam-role" } }) { - items { - id - name - version - description - dependencies { name resourceType { id } } - resources { name resourceType { id } } - } - cursor { next } - } - } - ``` - """ - bundles( - "Your organization's unique identifier." - organizationId: ID! - - "Narrow results by repository, type, or search term." - filter: BundlesFilter - - "Explicit sort order. Defaults to `name` ascending. When a `search` filter is active and no sort is provided, results are ranked by relevance." - sort: BundlesSort - - "Cursor from a previous page's `cursor.next` or `cursor.previous`." - cursor: Cursor - ): BundlesPage - - """ - Fetch a single bundle by its composite identifier. - - The `id` accepts a `name@version` string where the version portion can be an - exact semver, a release channel, or omitted entirely: - - | Input | Resolves to | - |-------|-------------| - | `aws-aurora-postgres@1.2.3` | Exact version `1.2.3` | - | `aws-aurora-postgres@~1.2` | Latest patch in `1.2.x` | - | `aws-aurora-postgres@~1` | Latest minor in `1.x.x` | - | `aws-aurora-postgres@latest` | Newest stable release | - | `aws-aurora-postgres@latest+dev` | Newest release including dev builds | - | `aws-aurora-postgres` | Shorthand for `latest` (falls back to `latest+dev` if no stable exists) | - - Returns `null` with a `NOT_FOUND` error if no matching version exists. - - ```graphql - query { - bundle(organizationId: "your-org-id", id: "aws-aurora-postgres@~1") { - id - version - dependencies { name required resourceType { id name } } - resources { name required resourceType { id name } } - } - } - ``` - """ - bundle( - "Your organization's unique identifier." - organizationId: ID! - - "Bundle identifier in `name@version` format. The version can be an exact semver, a release channel, or omitted to resolve `latest`." - id: BundleId! - ): Bundle - - """ - Fetch a single OCI repository by name. - - Returns the repository along with its nested `tags` and `releaseChannels` - collections. Returns `null` with a `NOT_FOUND` error if the repository - does not exist in your organization. - - ```graphql - query { - ociRepo(organizationId: "your-org-id", id: "aws-aurora-postgres") { - id - name - artifactType - tags(sort: { field: VERSION, order: DESC }) { - items { tag createdAt } - cursor { next } - } - releaseChannels(filter: { stable: true }) { - items { name tag } - } - } - } - ``` - """ - ociRepo( - "Your organization's unique identifier." - organizationId: ID! - - "The repository name (e.g., `aws-aurora-postgres`)." - id: ID! - ): OciRepo - - """ - List OCI repositories in your organization's bundle catalog. - - Returns a paginated list of repositories. Each repository is the container - for all published versions of a bundle. Use `filter` to narrow by name, - artifact type, or full-text search. - - ```graphql - query { - ociRepos(organizationId: "your-org-id", filter: { name: { startsWith: "aws-" } }) { - items { - id - name - tags(sort: { field: VERSION, order: DESC }) { - items { tag } - } - } - cursor { next } - } - } - ``` - """ - ociRepos( - "Your organization's unique identifier." - organizationId: ID! - - "Narrow results by name, artifact type, or search term." - filter: OciReposFilter - - "Explicit sort order. Defaults to `name` ascending. When a `search` filter is active and no sort is provided, results are ranked by relevance." - sort: OciReposSort - - "Cursor from a previous page's `cursor.next` or `cursor.previous`." - cursor: Cursor - ): OciReposPage - - """ - List all groups in your organization. - - Returns a paginated, sortable list of groups. By default, groups are sorted - alphabetically by name. - - **Example:** - - ```graphql - query { - groups(organizationId: "my-org") { - data { - id - name - role - description - } - cursor { next } - } - } - ``` - """ - groups( - "Your organization's unique identifier." - organizationId: ID! - - "Sort field and direction. Defaults to name ascending." - sort: GroupsSort - - "Pagination cursor returned from a previous response to fetch the next page." - cursor: Cursor - ): GroupsPage - - """ - Retrieve a single group by its identifier. - - Returns `null` with a `NOT_FOUND` error if the group does not exist or you do not have - permission to view it. - """ - group( - "Your organization's unique identifier." - organizationId: ID! - - "The group's unique identifier." - id: UUID! - ): Group - - """ - Fetch your organization's details, including custom attributes and logo. - - ```graphql - query { - organization(organizationId: "my-org") { - id - name - subscriptionStatus - customAttributes { items { key scope required } } - } - } - ``` - """ - organization( - "Your organization's unique identifier." - organizationId: ID! - ): Organization - - """ - Get information about the currently authenticated entity. - - Returns an `AccountViewer` for human users or a `ServiceAccountViewer` for API clients. - This is the "who am I?" query — use it to bootstrap user profiles, display org switchers, - check billing status, or verify which service account is making requests. - - Returns an authentication error if the request has no valid credentials. - """ - viewer: Viewer - - """ - Get server metadata and available authentication methods. - - This query does **not** require authentication and is intended to be the first - call a client makes. Use the response to determine which login methods to present - and to verify API compatibility via the server version. - - ```graphql - query { - server { - version - mode - appUrl - ssoProviders { - name - loginUrl - uiLabel - uiIconUrl - } - emailAuthMethods { - name - } - } - } - ``` - """ - server: Server! - - """ - List all supported integration types in the Massdriver catalog. - - Returns the full catalog of integrations that can be configured, along with their - JSON schemas for `config` and `auth` fields. Use this to discover available integrations - and build configuration forms. - - ```graphql - query { - integrationTypes(organizationId: "my-org") { - items { - id - name - description - configSchema - authSchema - } - } - } - ``` - """ - integrationTypes( - "Your organization's unique identifier." - organizationId: ID! - ): IntegrationTypesPage - - """ - List your organization's configured integrations. - - Returns all integrations that have been created for your organization, with support - for filtering by type and status, sorting, and cursor-based pagination. - - ```graphql - query { - integrations(organizationId: "my-org", filter: { status: { eq: enabled } }) { - cursor { next } - items { - id - status - nextRunAt - } - } - } - ``` - """ - integrations( - "Your organization's unique identifier." - organizationId: ID! - - "Filter criteria. All filters are combined with AND." - filter: IntegrationsFilter - - "Sort order. Defaults to `createdAt` ascending." - sort: IntegrationsSort - - "Pagination cursor from a previous response." - cursor: Cursor - ): IntegrationsPage - - """ - Get a single integration by its type identifier. - - Returns the integration configuration for a specific type within your organization, - or a `NOT_FOUND` error if no integration of that type has been configured. - """ - integration( - "Your organization's unique identifier." - organizationId: ID! - - "The integration type identifier (e.g., `\"aws-cost-and-usage-reports\"`)." - id: ID! - ): Integration - - """ - List audit log events for your organization. - - Returns a paginated, filterable, sortable list of audit log events. By default, events are - sorted newest first (`occurredAt` descending). - - **Filtering** — Use the `filter` argument to narrow results by time range, event type, - actor type, or specific actor. All filters are combined with AND logic. - - **Example:** - - ```graphql - query { - auditLogs( - organizationId: "my-org" - filter: { - occurredAt: { gte: "2025-01-01T00:00:00Z" } - type: { eq: "project.created" } - actorType: { in: [ACCOUNT, SERVICE_ACCOUNT] } - actor: { search: "alice" } - } - ) { - items { - id - occurredAt - type - subject - actor { type name } - } - cursor { next } - } - } - ``` - """ - auditLogs( - "Your organization's unique identifier." - organizationId: ID! - - "Optional filter criteria to narrow results." - filter: AuditLogsFilter - - "Sort field and direction. Defaults to newest first." - sort: AuditLogsSort - - "Pagination cursor from a previous response to fetch the next page." - cursor: Cursor - ): AuditLogsPage - - """ - Retrieve a single audit log event by its identifier. - - Returns `null` with a `NOT_FOUND` error if the event does not exist or belongs to a - different organization. - """ - auditLog( - "Your organization's unique identifier." - organizationId: ID! - - "The audit log event's unique identifier." - id: ID! - ): AuditLog - - """ - Return the complete catalog of audit log event types emitted by Massdriver. - - Event types are dot-notated strings (e.g., `project.created`, `deployment.completed`) - that categorize audit log events. This query returns the full static list — not just - the types that currently have events in your organization — so it's ideal for - populating a filter dropdown or building a dashboard that groups events by category. - - The list is sorted alphabetically and does not require pagination. - """ - auditLogEventTypes( - "Your organization's unique identifier." - organizationId: ID! - ): [String!]! - - """ - List resource types available to your organization. - - Returns both public resource types provided by Massdriver and any private - resource types defined by your organization. Use this to discover what - dependency and resource types are available when building bundles. - - ```graphql - query { - resourceTypes(organizationId: "your-org-id", filter: { id: { startsWith: "aws-" } }) { - items { - id - name - connectionOrientation - icon - } - cursor { next } - } - } - ``` - """ - resourceTypes( - "Your organization's unique identifier." - organizationId: ID! - - "Narrow results by resource type identifier or search term." - filter: ResourceTypesFilter - - "Sort order. Defaults to `name` ascending. When a `search` filter is active and no sort is provided, results are ranked by relevance." - sort: ResourceTypesSort - - "Cursor from a previous page's `cursor.next` or `cursor.previous`." - cursor: Cursor - ): ResourceTypesPage - - """ - Fetch a single resource type by its identifier. - - Returns `null` with a `NOT_FOUND` error if the resource type does not exist - or is not accessible to your organization. - - ```graphql - query { - resourceType(organizationId: "your-org-id", id: "aws-iam-role") { - id - name - connectionOrientation - icon - } - } - ``` - """ - resourceType( - "Your organization's unique identifier." - organizationId: ID! - - "The resource type identifier (e.g., `aws-iam-role`)." - id: ID! - ): ResourceType - - """ - List all resources in your organization. - - Returns a cursor-paginated list of both imported and provisioned resources. - Use the `filter` argument to view only imported or only provisioned resources. - - ```graphql - query { - resources(organizationId: "my-org", filter: { origin: { eq: imported } }) { - items { - id - name - origin - resourceType { id } - } - cursor { next } - } - } - ``` - """ - resources( - "Your organization's unique identifier." - organizationId: ID! - - "Optional filters to narrow the results." - filter: ResourcesFilter - - "Sort order for results. Defaults to alphabetical by name. When a `search` filter is active and no sort is provided, results are ranked by relevance." - sort: ResourcesSort - - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): ResourcesPage - - """ - Fetch a single resource by its unique identifier. - - Returns the full resource record including its origin, resource type, and timestamps. - """ - resource( - "Your organization's unique identifier." - organizationId: ID! - - "The resource's unique identifier." - id: ID! - ): Resource - - """ - List access tokens you own in an organization. - - Returns only tokens owned by the authenticated caller. Accounts see their own personal - tokens; service accounts see tokens issued to themselves. There is no admin view of - another user's personal tokens. - - **Example:** - - ```graphql - query { - accessTokens( - organizationId: "my-org" - filter: { revoked: false } - sort: { field: EXPIRES_AT, order: ASC } - ) { - items { id name prefix expiresAt } - cursor { next } - } - } - ``` - """ - accessTokens( - "Your organization's unique identifier." - organizationId: ID! - - "Optional filters to narrow results." - filter: AccessTokensFilter - - "Sort field and direction. Defaults to newest first." - sort: AccessTokensSort - - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): AccessTokensPage - - """ - Return the complete catalog of ABAC actions available in Massdriver. - - Actions are the building blocks of policies. Each action has an id in - `{entity}:{verb}` form (for example `project:view`, `instance:deploy`) and - a human-readable description. The full list is small and static, so this - query returns every action in a single response — no pagination. - - Results are sorted alphabetically by id. - - **Visibility model:** if you can view a project, you can see every - environment and instance within it. There is no `environment:view` or - `instance:view` primitive. Sensitive payloads are protected by - `resource:export` and `repo:pull` (both audit-logged), not by hiding - topology. - - **Example:** - - ```graphql - query PolicyActions { - policyActions(organizationId: "my-org") { - id - verb - description - entity { id description } - } - } - ``` - """ - policyActions( - "Your organization's unique identifier." - organizationId: ID! - ): [PolicyAction!]! - - """ - Return the complete catalog of ABAC entities — the kinds of things an - action can apply to. - - An entity is the `{entity}` portion of an action id. Use this alongside - `policyActions` when you want to group actions by what they apply to - (for example rendering a UI that collapses all `project:*` actions under a - single "Projects" header). - - Results are sorted alphabetically by id. - """ - policyEntities( - "Your organization's unique identifier." - organizationId: ID! - ): [PolicyEntity!]! - - """ - Render a policy spec — the same input shape as `createGroupPolicy` — as a - list of plain-English sentences describing what it permits or blocks. - - The explanation reflects the engine's scope-aware evaluation: a condition - whose attribute key isn't reachable from a given action's entity is - dropped before rendering, which can widen that action to be unconditional. - Pair this with the policy editor so authors see exactly what the policy - will allow before saving. - - Conditions referencing undeclared custom attribute keys are silently - ignored — the explainer is lenient by design, so typos surface as a - "wider than expected" sentence rather than a hard error. - - **Example input:** - - ```graphql - { - effect: ALLOW - actions: ["project:create", "project:update", "environment:create"] - conditions: "{\"md-environment\":[\"dev\",\"staging\",\"prod\"]}" - } - ``` - - **Example output:** - - ``` - [ - "Can create environments with identifiers [dev, staging, prod].", - "Can create and update any project." - ] - ``` - """ - explainPolicy( - "Your organization's unique identifier." - organizationId: ID! - - "The policy spec to explain — same shape as the `createGroupPolicy` input." - input: CreateGroupPolicyInput! - ): [String!]! - - """ - Return the catalog of V2 event types. - - Filter by `entity` to narrow the list. - - **Example:** - - ```graphql - query { - eventTypes(organizationId: "my-org") { - id - description - entity - action - changeType - } - } - ``` - """ - eventTypes( - "Your organization's unique identifier." - organizationId: ID! - - "Optional filter criteria to narrow results." - filter: EventTypesFilter - - "Sort field and direction. Defaults to alphabetical by id." - sort: EventTypesSort - ): [EventType!]! - - """ - List the service accounts in your organization. - - Returns a cursor-paginated list. Use `filter` to narrow by id or to search across each - service account's name and description. Use `sort` to control ordering (defaults to name - ascending; relevance ranking is used instead when `search` is active and no `sort` is given). - Requires `organization_admin` permissions. - - ```graphql - query { - serviceAccounts(organizationId: "my-org", filter: { search: "deploy" }) { - items { id name description createdAt } - cursor { next } - } - } - ``` - - **Note:** when a `search` filter is active, pagination cursors are offset-based and are - not interchangeable with the keyset cursors returned by non-search queries. - """ - serviceAccounts( - "Your organization's unique identifier." - organizationId: ID! - - "Narrow results by id or full-text search." - filter: ServiceAccountsFilter - - "Explicit sort order. Defaults to `name` ascending. When a `search` filter is active and no sort is provided, results are ranked by relevance." - sort: ServiceAccountsSort - - "Cursor from a previous page's `cursor.next` or `cursor.previous`." - cursor: Cursor - ): ServiceAccountsPage - - "Fetch a single service account by id. Requires `organization_admin` permissions." - serviceAccount( - "Your organization's unique identifier." - organizationId: ID! - - "The service account's unique identifier." - id: UUID! - ): ServiceAccount - - """ - Fetch a single alarm by its unique identifier. - - Returns the alarm with its most recent `currentState` attached, or a - `NOT_FOUND` error if the alarm doesn't exist or its project isn't - visible to the caller. - - ```graphql - query { - instanceAlarm(organizationId: "my-org", id: "0192…uuid") { - id - displayName - currentState { status occurredAt } - metric { namespace name } - } - } - ``` - """ - instanceAlarm( - "Your organization's unique identifier." - organizationId: ID! - - "The alarm's unique identifier." - id: UUID! - ): Alarm - - """ - List alarms across all projects you can see. - - Returns a paginated list. Use `filter` to narrow by project, environment, - component, bundle (`ociRepoName`), or instance. Default sort is - alphabetical by `displayName`. - - ```graphql - query { - instanceAlarms( - organizationId: "my-org" - filter: { environmentId: { eq: "prod" }, ociRepoName: { eq: "aws-rds" } } - ) { - items { id displayName currentState { status occurredAt } } - cursor { next } - } - } - ``` - """ - instanceAlarms( - "Your organization's unique identifier." - organizationId: ID! - - "Narrow results by project, environment, component, bundle, or instance." - filter: InstanceAlarmsFilter - - "Sort field and direction. Defaults to `displayName` ascending." - sort: InstanceAlarmsSort - - "Pagination cursor returned by a previous page." - cursor: Cursor - ): AlarmsPage - - """ - Evaluate whether the authenticated subject is permitted to perform a - single action on a single entity. - - Returns `allowed: false` (not an error) for entities that don't exist or - that belong to a different organization, so that the caller can't probe - for their existence. Returns a `NOT_FOUND` error when `action` is not - in the policy catalog or refers to an entity that has no addressable id. - """ - evaluatePolicy( - "Your organization's unique identifier." - organizationId: ID! - - "Action id in `entity:verb` form (for example `project:view`). Query `policyActions` for the full catalog." - action: String! - - "The identifier of the entity (e.g., a project's identifier)." - entityId: ID! - ): PolicyDecision! - - """ - Evaluate multiple `(action, entityId)` permissions in a single request. - The list of `checks` is capped at 10 entries. - - Decisions are returned in the same order as `checks`. Each decision - echoes its inputs so callers can correlate without relying on positional - indices. Same not-found / cross-org behavior as `evaluatePolicy`. If any - check references an action outside the policy catalog (or one that - can't be evaluated against an id) the whole request returns a - `NOT_FOUND` error. - """ - evaluatePolicies( - "Your organization's unique identifier." - organizationId: ID! - - "The list of permissions to evaluate. Capped at 10 entries." - checks: [PolicyDecisionInput!]! - ): [PolicyDecision!]! - - """ - Returns a JSON Schema document describing the custom-attribute keys and - permitted values for a given action, narrowed by the caller's policies. - - The schema is the org's custom-attribute schema for the action's scope - (e.g. `project:create` → project-scope attributes), with each key's - `enum` reduced to the values the caller's allow policies permit for - that action. Org admins see the full closed set; non-admins with no - matching policy receive `{"properties": {}, "additionalProperties": false}`, - which rejects any write. Actions whose entity has no custom-attribute - scope (group, organization, resource, resource_type) return - `{"type": "object"}` (no constraints). - - Renders well as a form schema for `createProject`, `createEnvironment`, - `setComponentAttributes`, etc. — only the choices the API will actually - accept on write are surfaced. - - Returns a `NOT_FOUND` error when `action` is not in the policy catalog. - """ - customAttributeSchema( - "Your organization's unique identifier." - organizationId: ID! - - "Action id in `entity:verb` form (for example `project:create`). Query `policyActions` for the catalog." - action: String! - ): Map! - - """ - Returns the closed set of values declared for one custom attribute. - - Equivalent to looking up `customAttribute.values` for a single - `(scope, key)` pair without paginating through `organization.customAttributes`. - Useful for populating a single dropdown (e.g. a `TEAM` picker) by key. - - Returns a `NOT_FOUND` error when `(scope, key)` does not correspond to a - declared custom attribute in the organization. - """ - customAttributeValues( - "Your organization's unique identifier." - organizationId: ID! - - "The custom attribute's declared scope (matches `customAttribute.scope`)." - scope: AttributeScope! - - "The custom attribute's key (matches `customAttribute.key`)." - key: String! - ): [String!]! -} - -type RootSubscriptionType { - """ - Subscribe to organization-level events via WebSocket. - - Receives events for projects created in the organization, OCI repositories - created in the bundle catalog, and bundle versions published to those - repositories. Requires organization membership. - - ```graphql - subscription { - organizationEvents(organizationId: "my-org") { - ... on Event { action timestamp } - ... on ProjectEvent { - project { id name } - } - ... on OciRepoEvent { - ociRepo { id name } - } - ... on BundleEvent { - bundle { id name version } - } - } - } - ``` - """ - organizationEvents( - "Your organization's unique identifier." - organizationId: ID! - ): OrganizationEventsPayload - - """ - Subscribe to events within a specific project via WebSocket. - - Receives events for the project itself, its environments, and its blueprint - (components and links). Requires read access to the project. - - ```graphql - subscription { - projectEvents(organizationId: "my-org", projectId: "my-project") { - ... on Event { action timestamp } - ... on ProjectEvent { - project { id name } - } - ... on EnvironmentEvent { - environment { id name } - } - ... on ComponentEvent { - component { id name } - } - ... on LinkEvent { - link { id fromField toField } - } - } - } - ``` - """ - projectEvents( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the project to subscribe to." - projectId: ID! - ): ProjectEventsPayload - - """ - Subscribe to events within a specific environment via WebSocket. - - Receives updates and deletions for the environment itself, lifecycle events - for its default resources (set/removed), and creation, update, and deletion - events for every instance, connection, and alarm in that environment - (including alarm firing-state transitions). Environment creation events are - delivered via `projectEvents` (the environment must exist before you can - subscribe to it). - - ```graphql - subscription { - environmentEvents(organizationId: "my-org", environmentId: "my-project-prod") { - ... on Event { action timestamp } - ... on EnvironmentEvent { - environment { id name } - } - ... on EnvironmentDefaultEvent { - environmentDefault { id resource { id name } } - } - ... on InstanceEvent { - instance { id name status } - } - ... on ConnectionEvent { - connection { id fromField toField } - } - ... on AlarmEvent { - alarm { id displayName currentState { status message } } - } - } - } - ``` - """ - environmentEvents( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the environment to subscribe to." - environmentId: ID! - ): EnvironmentEventsPayload - - """ - Subscribe to events for a specific instance via WebSocket. - - Receives events when the instance's configuration changes, a deployment is created, - an incoming connection is added or removed, or a cloud metric alarm on the instance - is registered, updated, deleted, or changes firing state. Requires read access to - the instance's project. - - ```graphql - subscription { - instanceEvents(organizationId: "my-org", instanceId: "my-database") { - ... on Event { action timestamp } - ... on InstanceEvent { - instance { id name status } - } - ... on ConnectionEvent { - connection { id fromField toField } - } - ... on AlarmEvent { - alarm { id displayName currentState { status message } } - } - } - } - ``` - """ - instanceEvents( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the instance to subscribe to." - instanceId: ID! - ): InstanceEventsPayload - - """ - Subscribe to lifecycle events for a single deployment via WebSocket. - - Receives a `DeploymentEvent` when the deployment is created or transitions - status (e.g., `PENDING` → `RUNNING` → `COMPLETED`). For log deltas use - `deploymentLogs` instead — this subscription does not fire on log appends. - - Re-select whatever fields you need on `deployment` — Apollo will merge the - payload into its cache by `Deployment:id`. - - ```graphql - subscription { - deploymentEvents(organizationId: "my-org", deploymentId: "a1b2c3...") { - ... on Event { action timestamp } - ... on DeploymentEvent { - deployment { - id - status - elapsedTime - } - } - } - } - ``` - """ - deploymentEvents( - "Your organization's unique identifier." - organizationId: ID! - - "The deployment's unique identifier." - deploymentId: ID! - ): DeploymentEventsPayload - - """ - Subscribe to a single deployment's logs as they're appended via WebSocket. - - Receives one `DeploymentLog` per worker flush — each flush is a batch of - lines written as a single object in storage. Unlike `deploymentEvents`, - this subscription does **not** re-send the entire deployment on every - message; it streams only the new log batch. Cheap to keep open even for - deployments producing logs at high cadence. - - For the initial backfill, query `deployment { logs { ... } }` once and - render the result, then open this subscription to stream new batches. - - ```graphql - subscription { - deploymentLogs(organizationId: "my-org", deploymentId: "a1b2c3...") { - timestamp - message - } - } - ``` - """ - deploymentLogs( - "Your organization's unique identifier." - organizationId: ID! - - "The deployment's unique identifier." - deploymentId: ID! - ): DeploymentLog! -} - -type RootMutationType { - """ - Create a new project in your organization. - - The `id` in the input becomes the project's permanent identifier and cannot be - changed after creation. An empty blueprint is created automatically. - """ - createProject( - "Your organization's unique identifier." - organizationId: ID! - - "Create a new project. A project is the complete model of your application—its infrastructure, architecture, configurations, and environments." - input: CreateProjectInput! - ): ProjectPayload @inputs(name: "createProject", schema: "\/graphql\/v2\/inputs\/createProject.json", ui: "\/graphql\/v2\/inputs\/createProject.ui.json") - - "Update a project's mutable fields (name, description, attributes)." - updateProject( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the project to update." - id: ID! - - "Update an existing project's name and description. The ID cannot be changed after creation." - input: UpdateProjectInput! - ): ProjectPayload @inputs(name: "updateProject", schema: "\/graphql\/v2\/inputs\/updateProject.json", ui: "\/graphql\/v2\/inputs\/updateProject.ui.json") - - """ - Create a new project by cloning another project's blueprint. - - All components and links from the source project are copied into the new project. - The new project gets its own independent blueprint -- subsequent changes do not - affect the source. Environments are **not** cloned; you must create them separately. - """ - cloneProject( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the project whose blueprint you want to clone." - sourceProjectId: ID! - - "Attributes for the new project." - input: CloneProjectInput! - ): ProjectPayload @inputs(name: "cloneProject", schema: "\/graphql\/v2\/inputs\/cloneProject.json", ui: "\/graphql\/v2\/inputs\/cloneProject.ui.json") - - """ - Delete a project permanently. - - All environments must be deleted first. Query the project's `deletable` field - to check for blocking constraints before calling this mutation. - """ - deleteProject( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the project to delete." - id: ID! - ): ProjectPayload - - """ - Create a new environment in a project. - - The `id` in the input becomes the environment's permanent identifier and cannot be - changed after creation. The new environment starts empty with no deployed instances. - """ - createEnvironment( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the project to create the environment in." - projectId: ID! - - "Create a new environment. Environments are isolated deployment contexts like production, staging, or development, each with independent secrets and configurations." - input: CreateEnvironmentInput! - ): EnvironmentPayload @inputs(name: "createEnvironment", schema: "\/graphql\/v2\/inputs\/createEnvironment.json", ui: "\/graphql\/v2\/inputs\/createEnvironment.ui.json") - - "Update an environment's mutable fields (name, description, attributes)." - updateEnvironment( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the environment to update." - id: ID! - - "Update an existing environment's name and description. The ID cannot be changed after creation." - input: UpdateEnvironmentInput! - ): EnvironmentPayload @inputs(name: "updateEnvironment", schema: "\/graphql\/v2\/inputs\/updateEnvironment.json", ui: "\/graphql\/v2\/inputs\/updateEnvironment.ui.json") - - """ - Create a new environment by forking an existing one. - - The new environment is linked to the parent via its `parent` field. Instances - are initialized from the project's components and seeded with the parent's - instance `params`. Secrets and remote references are not copied — use - `copyInstance` per instance to carry those over as well. Pass - `copyEnvironmentDefaults: true` to also copy the parent's default resource - connections. - """ - forkEnvironment( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the environment to fork from." - parentId: ID! - - "Attributes for the new environment. The fork references the parent via `parentId`, starts with blank instances, and does not copy any instance-level configuration. Use `copyInstance` per-instance if you want to seed configuration from the parent." - input: ForkEnvironmentInput! - ): EnvironmentPayload @inputs(name: "forkEnvironment", schema: "\/graphql\/v2\/inputs\/forkEnvironment.json", ui: "\/graphql\/v2\/inputs\/forkEnvironment.ui.json") - - """ - Delete an environment permanently. - - All instances must be decommissioned first. Query the environment's `deletable` - field to check for blocking constraints before calling this mutation. - """ - deleteEnvironment( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the environment to delete." - id: ID! - ): EnvironmentPayload - - """ - Set a resource as the default of its type for an environment. - - All instances in the environment that require this resource type will automatically - inherit it. Only one resource per type can be the default -- remove the existing - default first with `removeEnvironmentDefault` to change it. - """ - setEnvironmentDefault( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the environment to set the default on." - environmentId: ID! - - "The identifier of the resource to set as the default for its type." - resourceId: ID! - ): EnvironmentDefaultPayload - - """ - Remove an environment default. - - Instances will no longer automatically inherit this resource. **Warning:** removing - a default can cause future deployments to fail if instances depend on the resource - type that was provided by this default. - """ - removeEnvironmentDefault( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the environment default to remove." - id: UUID! - ): EnvironmentDefaultPayload - - """ - Add a component to a project's blueprint. - - Creates a new slot in the blueprint for the specified bundle. The component - does not deploy anything on its own -- it defines *what* can be deployed. - Instances are created in each environment when you deploy. - - ```graphql - mutation { - addComponent( - organizationId: "my-org" - projectId: "my-project" - ociRepoName: "aws-aurora-postgres" - input: { id: "database", name: "Primary Database" } - ) { - result { id name } - successful - } - } - ``` - """ - addComponent( - "Your organization's unique identifier." - organizationId: ID! - - "The project to add the component to." - projectId: ID! - - "The bundle's OCI repository name (e.g., `aws-aurora-postgres`)." - ociRepoName: OciRepoName! - - "Add an infrastructure component to a project's blueprint. Each component is a specific instance of a bundle (like a Redis cache or PostgreSQL database) that composes with other components to form your application." - input: AddComponentInput! - ): ComponentPayload @inputs(name: "addComponent", schema: "\/graphql\/v2\/inputs\/addComponent.json", ui: "\/graphql\/v2\/inputs\/addComponent.ui.json") - - """ - Remove a component from a project's blueprint. - - Deletes the component and all of its links. Any instances deployed from - this component must be decommissioned first. - """ - removeComponent( - "Your organization's unique identifier." - organizationId: ID! - - "The component ID to remove (e.g., `myproj-database`)." - id: ID! - ): ComponentPayload - - """ - Update a component's mutable fields (name, description, attributes). - - The component ID and underlying bundle cannot be changed after creation. - Attributes are validated against the organization's custom-attribute schema - for the component scope. - - ```graphql - mutation { - updateComponent( - organizationId: "my-org" - id: "my-project-database" - input: { name: "Primary Database", description: "User data" } - ) { - result { id name description } - successful - } - } - ``` - """ - updateComponent( - "Your organization's unique identifier." - organizationId: ID! - - "The component ID to update (e.g., `myproj-database`)." - id: ID! - - "Update an existing component's name, description, and attributes. The component ID and underlying bundle cannot be changed." - input: UpdateComponentInput! - ): ComponentPayload @inputs(name: "updateComponent", schema: "\/graphql\/v2\/inputs\/updateComponent.json", ui: "\/graphql\/v2\/inputs\/updateComponent.ui.json") - - """ - Create a link between two components. - - Declares that the `fromField` output of one component should be wired to the - `toField` input of another. Massdriver validates that the resource types are - compatible before creating the link. When environments are deployed, each link - becomes a **connection** that carries the actual resource data between instances. - - ```graphql - mutation { - linkComponents( - organizationId: "my-org" - input: { - fromComponentId: "my-project-database" - fromField: "authentication" - fromVersion: "~1.0" - toComponentId: "my-project-app" - toField: "database" - toVersion: "~2.0" - } - ) { - result { id fromField toField } - successful - } - } - ``` - """ - linkComponents( - "Your organization's unique identifier." - organizationId: ID! - - "Create a link between two components in a project's blueprint. Links connect an output field on the source component to an input field on the destination component, establishing data flow between infrastructure resources." - input: LinkComponentsInput! - ): LinkPayload @inputs(name: "linkComponents", schema: "\/graphql\/v2\/inputs\/linkComponents.json", ui: "\/graphql\/v2\/inputs\/linkComponents.ui.json") - - """ - Remove a link between two components. - - Deletes the design-time dependency. Existing connections in deployed - environments are not affected until the next deployment. - """ - unlinkComponents( - "Your organization's unique identifier." - organizationId: ID! - - "The link ID to remove." - id: UUID! - ): LinkPayload - - "Set the pixel position of a component on the visual canvas." - setComponentPosition( - "Your organization's unique identifier." - organizationId: ID! - - "The component ID to reposition (e.g., `myproj-database`)." - id: ID! - - "Set the position of a component on the canvas." - input: SetComponentPositionInput! - ): ComponentPayload @inputs(name: "setComponentPosition", schema: "\/graphql\/v2\/inputs\/setComponentPosition.json", ui: "\/graphql\/v2\/inputs\/setComponentPosition.ui.json") - - """ - Accept a pending group invitation. - - Accepting an invitation makes you a member of the group and grants you whatever access - the group provides. The invitation is consumed and removed from your pending invitations - list. You must be the recipient of the invitation (matched by email) to accept it. - """ - acceptGroupInvite( - "The invitation's unique identifier." - id: UUID! - ): InviteViewerPayload - - """ - Upload or replace your profile avatar. - - Accepts PNG, JPG, GIF, or WebP images up to 1 MB. If you already have an avatar, it is - replaced. The avatar is served at a stable URL that does not change when the image is updated. - """ - setAccountAvatar( - "The image file to upload (PNG, JPG, GIF, or WebP, max 1 MB)." - avatar: Upload! - ): AvatarViewerPayload - - """ - Remove your profile avatar. - - Deletes the current avatar image. After removal, the `avatar` field on your viewer profile - returns `null`. - """ - removeAccountAvatar: AvatarViewerPayload - - """ - Create a new group in your organization. - - New groups are created with the `CUSTOM` role by default, allowing you to assign - project-level access grants after creation. Requires `organization_admin` permissions. - """ - createGroup( - "Your organization's unique identifier." - organizationId: ID! - - "Create a new group. Groups control which projects members can access." - input: CreateGroupInput! - ): GroupPayload @inputs(name: "createGroup", schema: "\/graphql\/v2\/inputs\/createGroup.json", ui: "\/graphql\/v2\/inputs\/createGroup.ui.json") - - """ - Update a group's name or description. - - Only the `name` and `description` fields can be modified. The group's role cannot be - changed after creation. Requires `organization_admin` permissions. - """ - updateGroup( - "Your organization's unique identifier." - organizationId: ID! - - "The group's unique identifier." - id: UUID! - - "Update a group's name or description." - input: UpdateGroupInput! - ): GroupPayload @inputs(name: "updateGroup", schema: "\/graphql\/v2\/inputs\/updateGroup.json", ui: "\/graphql\/v2\/inputs\/updateGroup.ui.json") - - """ - Delete a group. - - Only groups with the `CUSTOM` role can be deleted. The built-in `organization_admin` and - `organization_viewer` groups are permanent and cannot be removed. All members lose the - access granted by this group immediately upon deletion. Requires `organization_admin` permissions. - """ - deleteGroup( - "Your organization's unique identifier." - organizationId: ID! - - "The group's unique identifier." - id: UUID! - ): GroupPayload - - """ - Add an account to a group by email. - - Behavior depends on whether the email already belongs to an organization member: - - - **If the user is already in the organization**, they are added to the group directly - with no invitation step. The mutation returns the resulting `Account`. - - **If the user is not yet in the organization**, an invitation email is sent. The - mutation returns a `GroupInvitation`. The recipient joins the group when they accept. - - Use inline fragments on the union result to handle both branches in a single client call. - Requires the `group:manage` action on this group. - """ - addAccountToGroup( - "Your organization's unique identifier." - organizationId: ID! - - "The group to add the account to." - groupId: UUID! - - "Add an account to a group by email. If the email belongs to an existing organization member they are added directly; otherwise an invitation is sent." - input: AddAccountToGroupInput! - ): AddedAccountToGroupPayload @inputs(name: "addAccountToGroup", schema: "\/graphql\/v2\/inputs\/addAccountToGroup.json", ui: "\/graphql\/v2\/inputs\/addAccountToGroup.ui.json") - - """ - Revoke a pending invitation. - - Removes the invitation so the user can no longer accept it. Has no effect if the invitation - was already accepted. Requires `organization_admin` permissions. - """ - deleteGroupInvitation( - "Your organization's unique identifier." - organizationId: ID! - - "The group the invitation belongs to." - groupId: UUID! - - "Email address of the invitation to revoke." - email: String! - ): GroupInvitationPayload - - """ - Remove a member from a group. - - The member immediately loses any access granted by this group. If this was their only - group in the organization, they lose all access to the organization. - Requires `organization_admin` permissions. - """ - deleteGroupMember( - "Your organization's unique identifier." - organizationId: ID! - - "The group to remove the member from." - groupId: UUID! - - "Email address of the member to remove." - email: String! - ): DeletedGroupMemberPayload - - """ - Declare a new custom attribute in your organization. - - Once declared, the attribute immediately applies to new resources at the specified scope. - Existing resources are not retroactively validated. Requires the `organization:manage` action. - """ - createCustomAttribute( - "Your organization's unique identifier." - organizationId: ID! - - "Declare a custom attribute key for your organization. Custom attributes control which user-defined attribute keys can be set on resources at each level of the hierarchy. System attributes (md-*) are auto-injected by Massdriver and do not need to be declared." - input: CreateCustomAttributeInput! - ): CustomAttributePayload @inputs(name: "createCustomAttribute", schema: "\/graphql\/v2\/inputs\/createCustomAttribute.json", ui: "\/graphql\/v2\/inputs\/createCustomAttribute.ui.json") - - """ - Replace the closed set of values for an existing custom attribute. - - Changing `values` does not retroactively validate or rewrite resources - that were tagged before this update. Subsequent writes (and policy conditions - referencing this key) must use a value from the new set. Requires the - `organization:manage` action. - """ - updateCustomAttribute( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the custom attribute to update." - id: UUID! - - "Replace the closed set of values for an existing custom attribute. The key and scope are immutable." - input: UpdateCustomAttributeInput! - ): CustomAttributePayload @inputs(name: "updateCustomAttribute", schema: "\/graphql\/v2\/inputs\/updateCustomAttribute.json", ui: "\/graphql\/v2\/inputs\/updateCustomAttribute.ui.json") - - """ - Delete a custom attribute from your organization. - - Removing a custom attribute stops enforcing the rule for future resources. - Existing attributes on resources are not removed. Requires the `organization:manage` action. - """ - deleteCustomAttribute( - "Your organization's unique identifier." - organizationId: ID! - - "The identifier of the custom attribute to delete." - id: UUID! - ): CustomAttributePayload - - """ - Create a new organization. You become the owner and first admin automatically. - - The `id` in the input becomes the organization's permanent identifier and cannot be - changed after creation. - """ - createOrganization( - "Create a new organization. An organization is the top-level container for all your projects, environments, and infrastructure resources." - input: CreateOrganizationInput! - ): OrganizationPayload @inputs(name: "createOrganization", schema: "\/graphql\/v2\/inputs\/createOrganization.json", ui: "\/graphql\/v2\/inputs\/createOrganization.ui.json") - - "Update mutable organization settings. Requires the `organization:manage` action." - updateOrganization( - "Your organization's unique identifier." - organizationId: ID! - - "Update mutable settings on an organization. The identifier is fixed at creation and cannot be changed." - input: UpdateOrganizationInput! - ): OrganizationPayload @inputs(name: "updateOrganization", schema: "\/graphql\/v2\/inputs\/updateOrganization.json", ui: "\/graphql\/v2\/inputs\/updateOrganization.ui.json") - - """ - Upload or replace your organization's logo. - - Accepts PNG, JPG, GIF, or WebP images up to 1 MB. If a logo already exists, - it is replaced. Use `removeOrganizationLogo` to delete without replacement. - Requires the `organization:manage` action. - """ - setOrganizationLogo( - "Your organization's unique identifier." - organizationId: ID! - - "The image file to upload (PNG, JPG, GIF, or WebP, max 1 MB)." - logo: Upload! - ): LogoOrganizationPayload - - """ - Remove your organization's logo. Returns the deleted logo's metadata, or `null` if none existed. - Requires the `organization:manage` action. - """ - removeOrganizationLogo( - "Your organization's unique identifier." - organizationId: ID! - ): LogoOrganizationPayload - - """ - Remove a member from the organization. - - This revokes all group memberships and cancels any pending invitations for the - specified email address. The member immediately loses access to all organization - resources. Requires the `organization:manage` action. - """ - deleteOrganizationMember( - "Your organization's unique identifier." - organizationId: ID! - - "Email address of the member to remove." - email: String! - ): DeletedOrganizationMemberPayload - - """ - Create and activate an integration for your organization. - - Configures a new integration of the specified type. The `config` and `auth` fields - must conform to the JSON schemas returned by `integrationTypes`. Returns an - `IntegrationActivation` with one-time setup instructions that may include sensitive - credentials. - - Each organization can have at most one integration per type. - - ```graphql - mutation { - createIntegration( - organizationId: "my-org" - id: "aws-cost-and-usage-reports" - input: { - config: "{\"bucket\": \"my-cur-bucket\", \"region\": \"us-east-1\"}" - auth: "{\"roleArn\": \"arn:aws:iam::123456789012:role/MassdriverCUR\"}" - } - ) { - result { - id - status - instructions - } - successful - messages { field message } - } - } - ``` - """ - createIntegration( - "Your organization's unique identifier." - organizationId: ID! - - "The integration type to configure (e.g., `\"aws-cost-and-usage-reports\"`)." - id: ID! - - "Create and activate an integration for your organization. The config and auth payloads must conform to the integration type's configSchema and authSchema respectively." - input: CreateIntegrationInput! - ): IntegrationActivationPayload @inputs(name: "createIntegration", schema: "\/graphql\/v2\/inputs\/createIntegration.json", ui: "\/graphql\/v2\/inputs\/createIntegration.ui.json") - - """ - Re-enable a disabled integration. - - Runs the integration's activation process again and resumes its execution schedule. - Returns an `IntegrationActivation` with updated setup instructions. The integration - must currently be in `disabled` status. - """ - enableIntegration( - "Your organization's unique identifier." - organizationId: ID! - - "The integration type identifier to enable." - id: ID! - ): IntegrationActivationPayload - - """ - Disable an active integration. - - Stops the integration's execution schedule and runs its deactivation process. - The integration configuration is preserved and can be re-enabled later with - `enableIntegration`. The integration must currently be in `enabled` status. - """ - disableIntegration( - "Your organization's unique identifier." - organizationId: ID! - - "The integration type identifier to disable." - id: ID! - ): IntegrationPayload - - """ - Permanently delete an integration. - - Disables the integration if it is currently active, then removes the configuration - entirely. This action cannot be undone — to reconnect, you will need to create the - integration again with `createIntegration`. - """ - deleteIntegration( - "Your organization's unique identifier." - organizationId: ID! - - "The integration type identifier to delete." - id: ID! - ): IntegrationPayload - - """ - Create a scoped, time-limited access token for the authenticated identity. - - Accounts create personal access tokens for themselves; service accounts create tokens for - their own identity. - - The `token` field in the response contains the full bearer token value — **it is only - shown once**. Store it securely before navigating away. If you lose it, revoke the - token and create a new one. - - Defaults to a 60-minute expiration if `expiresInMinutes` is not specified. - - **Example:** - - ```graphql - mutation { - createAccessToken( - organizationId: "my-org" - input: { name: "deploy-token", scopes: ["*"], expiresInMinutes: 30 } - ) { - result { - id - token - prefix - expiresAt - } - successful - messages { field message } - } - } - ``` - """ - createAccessToken( - "Your organization's unique identifier." - organizationId: ID! - - "Create a scoped, time-limited access token for the authenticated identity." - input: CreateAccessTokenInput! - ): AccessTokenWithValuePayload @inputs(name: "createAccessToken", schema: "\/graphql\/v2\/inputs\/createAccessToken.json", ui: "\/graphql\/v2\/inputs\/createAccessToken.ui.json") - - """ - Revoke an access token. - - Only the owning subject can revoke a token — organization admins cannot revoke another - user's personal tokens. The token immediately stops working for all API requests. The - token record is preserved with a `revokedAt` timestamp for audit purposes. Revoking an - already-revoked or expired token is a no-op that returns the existing record. - """ - revokeAccessToken( - "Your organization's unique identifier." - organizationId: ID! - - "The access token's unique identifier." - id: UUID! - ): AccessTokenPayload - - """ - Create a new service account in your organization. - - A default access token is issued in the same request — its raw `token` value is returned - once on the response and **cannot be retrieved later**. Capture it before navigating away; - if it's lost, revoke the token and issue a new one via `createAccessToken`. - - The new service account has no permissions until you add it to a group. Requires - `organization_admin` permissions. - - **Example:** - - ```graphql - mutation { - createServiceAccount( - organizationId: "my-org" - input: { - name: "ci-deploy-bot" - description: "GitHub Actions deployer" - defaultAccessTokenExpirationInMinutes: 525600 - } - ) { - result { - id - name - defaultAccessToken { token prefix expiresAt } - } - successful - messages { field message } - } - } - ``` - """ - createServiceAccount( - "Your organization's unique identifier." - organizationId: ID! - - "Create a new service account for programmatic API access. Issues a default access token alongside the service account; the raw token value is returned once and cannot be retrieved later." - input: CreateServiceAccountInput! - ): ServiceAccountWithDefaultAccessTokenPayload @inputs(name: "createServiceAccount", schema: "\/graphql\/v2\/inputs\/createServiceAccount.json", ui: "\/graphql\/v2\/inputs\/createServiceAccount.ui.json") - - """ - Update a service account's name or description. - - Send only the fields you want to change. Requires `organization_admin` permissions. - """ - updateServiceAccount( - "Your organization's unique identifier." - organizationId: ID! - - "The service account's unique identifier." - id: UUID! - - "Update a service account's name or description. Both fields are optional; send only the fields you want to change." - input: UpdateServiceAccountInput! - ): ServiceAccountPayload @inputs(name: "updateServiceAccount", schema: "\/graphql\/v2\/inputs\/updateServiceAccount.json", ui: "\/graphql\/v2\/inputs\/updateServiceAccount.ui.json") - - """ - Permanently delete a service account. - - This immediately revokes all API access for this service account, including any active - access tokens. All group memberships are removed. This action cannot be undone. - Requires `organization_admin` permissions. - """ - deleteServiceAccount( - "Your organization's unique identifier." - organizationId: ID! - - "The service account's unique identifier." - id: UUID! - ): ServiceAccountPayload - - """ - Add a service account to a group, granting it the group's access level. - - The service account immediately gains all permissions associated with the group's role. - A service account can belong to multiple groups — its effective permissions are the union - of all group permissions. Requires `organization_admin` permissions. - """ - addServiceAccountToGroup( - "Your organization's unique identifier." - organizationId: ID! - - "The service account to add." - serviceAccountId: UUID! - - "The group to add the service account to." - groupId: UUID! - ): ServiceAccountGroupPayload - - """ - Remove a service account from a group. - - The service account immediately loses all permissions granted by this group. If this was - its only group, the service account retains its identity but has no access to any resources. - Requires `organization_admin` permissions. - """ - removeServiceAccountFromGroup( - "Your organization's unique identifier." - organizationId: ID! - - "The service account to remove." - serviceAccountId: UUID! - - "The group to remove the service account from." - groupId: UUID! - ): ServiceAccountGroupPayload - - """ - Override one of an instance's connection slots with a resource from another - project (or an imported resource). - - The instance must **not** be in `PROVISIONED` or `FAILED` status — like - other configuration changes, overrides cannot be set on a deployed instance. - - The override takes priority over any blueprint-level Link wired into the - same slot. Removing the override reverts to the Link (or environment default). - - ```graphql - mutation { - setRemoteReference( - organizationId: "my-org" - instanceId: "my-app" - resourceId: "shared-creds-abc123" - input: { field: "aws_authentication" } - ) { - result { id field resource { id name } } - successful - messages { field message } - } - } - ``` - """ - setRemoteReference( - "Your organization's unique identifier." - organizationId: ID! - - "The instance to set the remote reference on." - instanceId: ID! - - "The resource to reference — either a UUID for imported resources or 'instance.field' for provisioned resources." - resourceId: ID! - - "Link an instance's resource field to a resource from another project or an imported resource. The instance must not be in a provisioned or failed state." - input: SetRemoteReferenceInput! - ): RemoteReferencePayload @inputs(name: "setRemoteReference", schema: "\/graphql\/v2\/inputs\/setRemoteReference.json", ui: "\/graphql\/v2\/inputs\/setRemoteReference.ui.json") - - """ - Remove a per-instance remote-reference override. The slot reverts to its - blueprint Link (if any) or the environment default at the next deploy. - - The instance must **not** be in `PROVISIONED` or `FAILED` status — taking - an override off a deployed instance would change the resolved connection - map under the running deployment. - - ```graphql - mutation { - removeRemoteReference( - organizationId: "my-org" - instanceId: "my-app" - input: { field: "aws_authentication" } - ) { - result { id field } - successful - messages { field message } - } - } - ``` - """ - removeRemoteReference( - "Your organization's unique identifier." - organizationId: ID! - - "The instance the reference belongs to." - instanceId: ID! - - "Remove a remote reference from an instance. The reference can only be removed if no provisioned instances are connected through it." - input: RemoveRemoteReferenceInput! - ): RemoteReferencePayload @inputs(name: "removeRemoteReference", schema: "\/graphql\/v2\/inputs\/removeRemoteReference.json", ui: "\/graphql\/v2\/inputs\/removeRemoteReference.ui.json") - - """ - Import a new resource into your organization. - - Creates a manually-managed resource (origin: `IMPORTED`) such as cloud credentials, - a network configuration, or any other infrastructure output. The resource must - conform to the schema defined by the specified resource type. - - ```graphql - mutation { - createResource( - organizationId: "my-org" - resourceTypeId: "aws-iam-role" - input: { name: "CI/CD Role", payload: { arn: "arn:aws:iam::123:role/ci" } } - ) { - result { id name origin } - successful - messages { field message } - } - } - ``` - """ - createResource( - "Your organization's unique identifier." - organizationId: ID! - - "The resource type this resource conforms to (e.g., `aws-iam-role`)." - resourceTypeId: ID! - - "Import a new resource with a name and optional payload conforming to the resource type's schema." - input: CreateResourceInput! - ): ResourcePayload @inputs(name: "createResource", schema: "\/graphql\/v2\/inputs\/createResource.json", ui: "\/graphql\/v2\/inputs\/createResource.ui.json") - - """ - Update an imported resource's name or payload. - - Only resources with origin `IMPORTED` can be updated. Attempting to update a - `PROVISIONED` resource returns an error — those are managed by their owning - instance's deployment lifecycle. - """ - updateResource( - "Your organization's unique identifier." - organizationId: ID! - - "The resource to update." - id: ID! - - "Update a resource's name or payload. Provisioned resources can only have their name updated. Imported resources can also update their payload." - input: UpdateResourceInput! - ): ResourcePayload @inputs(name: "updateResource", schema: "\/graphql\/v2\/inputs\/updateResource.json", ui: "\/graphql\/v2\/inputs\/updateResource.ui.json") - - """ - Export a resource, returning it along with its unmasked `payload` and a `rendered` - copy in the requested `format` (defaults to `json`). - - Exports are recorded in the audit log so that access to sensitive payload data — - credentials, connection strings, IaC outputs — is attributable to the actor who - performed it. The resource itself is not modified. - - Works for both imported and provisioned resources. The caller must have permission - to view the resource. - """ - exportResource( - "Your organization's unique identifier." - organizationId: ID! - - "The resource to export." - id: ID! - - "Output format for the `rendered` field. `json` serializes the payload; other values (e.g. `yaml`, `env`) must be declared in the resource type's schema." - format: String - ): ResourceWithSensitiveValuesPayload - - """ - Delete an imported resource. - - Only resources with origin `IMPORTED` can be deleted directly. Provisioned resources - are automatically removed when their owning instance is decommissioned. - - The resource cannot be deleted if it is currently referenced by any active connections. - Disconnect all consumers before deleting. - """ - deleteResource( - "Your organization's unique identifier." - organizationId: ID! - - "The resource to delete." - id: ID! - ): ResourcePayload - - """ - **Deprecated — use at your own risk.** This mutation exists only to bridge V0's - `publishArtifactDefinition` into V2 while resource types are being migrated to OCI. - New integrations should use the OCI-native publishing flow. This mutation may be - removed or change behavior without notice. - - Upsert a resource type from a JSON Schema document. If an existing resource type in - your organization has the same `$md.name`, its schema is replaced; otherwise a new - resource type is created. - - The schema describes the shape of data this resource type exposes to dependents. It - must include a `$md` extension that declares the type's identifier (`$md.name`), - display label, icon, and UI behavior. - - ```graphql - mutation { - publishResourceType( - organizationId: "your-org-id" - input: { - schema: { - "$md": { name: "aws-iam-role", label: "AWS IAM Role" } - type: "object" - properties: { data: { type: "object", required: ["arn"], properties: { arn: { type: "string" } } } } - } - } - ) { - successful - result { id name } - messages { field message } - } - } - ``` - """ - publishResourceType( - "Your organization's unique identifier." - organizationId: ID! - - "Upsert a resource type for your organization from a JSON Schema document. If an existing resource type has the same identifier, its schema is replaced. **Deprecated:** this mutation exists solely to bridge V0 `publishArtifactDefinition` into the V2 API while resource types are being migrated to OCI. New integrations should use the OCI-native publishing flow — this mutation may be removed without notice." - input: PublishResourceTypeInput! - ): ResourceTypePayload @inputs(name: "publishResourceType", schema: "\/graphql\/v2\/inputs\/publishResourceType.json", ui: "\/graphql\/v2\/inputs\/publishResourceType.ui.json") @deprecated(reason: "Transitional shim from V0 `publishArtifactDefinition` during the migration to OCI-hosted resource types. Prefer the OCI-native publishing flow; this mutation may be removed without notice.") - - """ - **Deprecated — use at your own risk.** This mutation exists only to bridge V0's - `deleteArtifactDefinition` into V2 while resource types are being migrated to OCI. - New integrations should use the OCI-native publishing flow. This mutation may be - removed or change behavior without notice. - - Delete a resource type. The resource type cannot be deleted while it is still in - use — either as a dependency or output in a bundle, or by any existing - imported/provisioned resources of this type. Remove those consumers first. - """ - deleteResourceType( - "Your organization's unique identifier." - organizationId: ID! - - "The resource type identifier (e.g., `aws-iam-role`)." - id: ID! - ): ResourceTypePayload @deprecated(reason: "Transitional shim from V0 `deleteArtifactDefinition` during the migration to OCI-hosted resource types. Prefer the OCI-native publishing flow; this mutation may be removed without notice.") - - """ - Update an instance's version constraint or release strategy. - - Changes are staged and take effect on the **next** deployment. The - `resolvedVersion` field will reflect the new constraint immediately, - while `deployedVersion` remains unchanged until a deploy runs. - - ```graphql - mutation { - updateInstance( - organizationId: "my-org" - id: "my-database" - input: { version: "~2.0", releaseStrategy: stable } - ) { - result { id resolvedVersion } - successful - } - } - ``` - """ - updateInstance( - "Your organization's unique identifier." - organizationId: ID! - - "The instance ID to update." - id: ID! - - "Update an instance's version constraint or release strategy. Changes take effect on the next deployment." - input: UpdateInstanceInput! - ): InstancePayload @inputs(name: "updateInstance", schema: "\/graphql\/v2\/inputs\/updateInstance.json", ui: "\/graphql\/v2\/inputs\/updateInstance.ui.json") - - """ - Copy configuration from one instance to another. - - Both instances must be built from the same component. Source params (minus any - fields the bundle marks non-copyable) are written to the destination, deep-merged - with any `overrides`. A plan deployment is then created on the destination so the - caller can review the changes before applying them. `copySecrets` and - `copyRemoteReferences` opt in to additional state transfer. - - ```graphql - mutation { - copyInstance( - organizationId: "my-org" - sourceId: "ecomm-prod-db" - destinationId: "ecomm-staging-db" - input: { overrides: { size: "small" }, copySecrets: true, message: "Promote prod config" } - ) { - result { id params } - successful - } - } - ``` - """ - copyInstance( - "Your organization's unique identifier." - organizationId: ID! - - "The instance to copy configuration from." - sourceId: ID! - - "The instance to copy configuration into. Must be built from the same component as the source." - destinationId: ID! - - "Copy configuration from one instance to another. The source and destination must be instances of the same component. Source params (minus any fields marked non-copyable in the bundle) are written to the destination, then a plan deployment is created on the destination so the changes can be reviewed before applying." - input: CopyInstanceInput! - ): InstancePayload @inputs(name: "copyInstance", schema: "\/graphql\/v2\/inputs\/copyInstance.json", ui: "\/graphql\/v2\/inputs\/copyInstance.ui.json") - - """ - Create or update a secret on an instance. - - If a secret with the given name already exists, its value is replaced. - Secret values are encrypted at rest and **never returned** in API responses. - Secrets are injected into the deployment environment at deploy time. - """ - setInstanceSecret( - "Your organization's unique identifier." - organizationId: ID! - - "The instance to attach the secret to." - id: ID! - - "Create or update a secret on an instance. The secret value is encrypted at rest and never returned in API responses." - input: SetInstanceSecretInput! - ): InstanceSecretPayload @inputs(name: "setInstanceSecret", schema: "\/graphql\/v2\/inputs\/setInstanceSecret.json", ui: "\/graphql\/v2\/inputs\/setInstanceSecret.ui.json") - - """ - Remove a secret from an instance. - - The secret is permanently deleted. The change takes effect on the next deployment; - any currently running infrastructure retains the secret until redeployed. - """ - removeInstanceSecret( - "Your organization's unique identifier." - organizationId: ID! - - "The instance to remove the secret from." - id: ID! - - "The name of the secret to remove." - name: String! - ): InstanceSecretPayload - - """ - Create a new deployment for an instance. - - Saves the provided `params` as the instance's configuration, then initiates the - specified action: - - - **PROVISION** — apply the configuration to create or update infrastructure. - - **PLAN** — generate a dry-run preview without making changes. - - **DECOMMISSION** — destroy all infrastructure managed by the instance. - - The returned deployment starts in `PENDING` status and transitions to `RUNNING` - once execution begins. Poll the `deployment` query or use the `status` field - to monitor progress. - - ```graphql - mutation { - createDeployment( - organizationId: "my-org" - id: "my-database" - input: { action: PROVISION, params: { size: "large" }, message: "Scale up" } - ) { - result { id status action } - successful - messages { field message } - } - } - ``` - """ - createDeployment( - "Your organization's unique identifier." - organizationId: ID! - - "The instance to deploy." - id: ID! - - "Deploy an instance with configuration parameters. Params are validated against the bundle's params schema, cached on the instance, and snapshotted into the deployment." - input: CreateDeploymentInput! - ): DeploymentPayload @inputs(name: "createDeployment", schema: "\/graphql\/v2\/inputs\/createDeployment.json", ui: "\/graphql\/v2\/inputs\/createDeployment.ui.json") - - """ - Propose a deployment for human review. - - Creates a deployment in `PROPOSED` status. Proposed deployments are **not** - scheduled and do **not** block the queue — other deployments on the same - instance can continue to run while a proposal is outstanding. - - Approve a proposal with `approveDeployment` to release it into the run queue, - or discard it with `rejectDeployment`. - - Only `PROVISION` and `DECOMMISSION` actions are proposable — `PLAN` is - already a non-destructive preview and needs no approval gate. - - ```graphql - mutation { - proposeDeployment( - organizationId: "my-org" - id: "my-database" - input: { action: PROVISION, params: { size: "large" }, message: "Scale up for Black Friday" } - ) { - result { id status action } - successful - messages { field message } - } - } - ``` - """ - proposeDeployment( - "Your organization's unique identifier." - organizationId: ID! - - "The instance to propose a deployment for." - id: ID! - - "Propose a deployment for human review. The deployment is created in PROPOSED status and will not execute until it is approved via approveDeployment. Params are validated against the bundle's params schema and snapshotted into the proposal." - input: ProposeDeploymentInput! - ): DeploymentPayload @inputs(name: "proposeDeployment", schema: "\/graphql\/v2\/inputs\/proposeDeployment.json", ui: "\/graphql\/v2\/inputs\/proposeDeployment.ui.json") - - """ - Approve a proposed deployment, releasing it into the run queue. - - The deployment transitions from `PROPOSED` to `APPROVED` and will execute - as soon as nothing else is running on the instance. Approval is only valid - for deployments in `PROPOSED` status; any other status returns a validation - error. - """ - approveDeployment( - "Your organization's unique identifier." - organizationId: ID! - - "The proposed deployment's unique identifier." - id: UUID! - ): DeploymentPayload - - """ - Reject a proposed deployment, discarding it permanently. - - The deployment transitions from `PROPOSED` to `REJECTED`, which is terminal — - rejected deployments never run. Rejection is only valid for deployments in - `PROPOSED` status; any other status returns a validation error. - """ - rejectDeployment( - "Your organization's unique identifier." - organizationId: ID! - - "The proposed deployment's unique identifier." - id: UUID! - ): DeploymentPayload - - """ - Attach an ABAC policy to a group. - - Group policies control what users and service accounts who are members of - the group can do. Pair with `policyActions` to enumerate the catalog of - available actions, and the access-control guide for common patterns. - """ - createGroupPolicy( - "Your organization's unique identifier." - organizationId: ID! - - "The group to attach the policy to." - groupId: UUID! - - "Attach an ABAC policy to a group. Each policy grants or denies one or more actions on entities whose attributes satisfy the conditions." - input: CreateGroupPolicyInput! - ): PolicyPayload @inputs(name: "createGroupPolicy", schema: "\/graphql\/v2\/inputs\/createGroupPolicy.json", ui: "\/graphql\/v2\/inputs\/createGroupPolicy.ui.json") - - """ - Edit a group policy's `effect`, `actions`, or `conditions` in place. - - The group is fixed at create time and cannot be changed — to retarget a - policy, delete it and create a new one. Omit a field from the input to - leave it unchanged. Set `conditions` to `"*"` explicitly to clear - conditions and turn the policy into a wildcard. Setting `actions` - replaces the entire list (no merge). - """ - updatePolicy( - "Your organization's unique identifier." - organizationId: ID! - - "The policy's unique identifier." - id: UUID! - - "Edit an existing policy in place. The principal cannot be changed." - input: UpdatePolicyInput! - ): PolicyPayload @inputs(name: "updatePolicy", schema: "\/graphql\/v2\/inputs\/updatePolicy.json", ui: "\/graphql\/v2\/inputs\/updatePolicy.ui.json") - - "Delete a policy by its id." - deletePolicy( - "Your organization's unique identifier." - organizationId: ID! - - "The policy's unique identifier." - id: UUID! - ): PolicyPayload - - """ - Register a cloud metric alarm with an instance. - - The alarm appears in the UI immediately and starts receiving state updates - as soon as the cloud provider reports them. The `cloudResourceId` is used - to correlate inbound webhooks (CloudWatch, Azure Monitor, GCP Cloud - Monitoring, Alertmanager) back to this alarm — it must be unique within - the instance. - - Requires `environment:update` on the alarm's environment. - """ - createInstanceAlarm( - "Your organization's unique identifier." - organizationId: ID! - - "The instance to attach the alarm to." - instanceId: ID! - - "Register a cloud metric alarm with an instance. The alarm appears in the UI immediately and receives state transitions as soon as the cloud provider reports them. Webhooks from AWS CloudWatch, Azure Monitor, GCP Cloud Monitoring, and Prometheus Alertmanager match against `cloudResourceId` to attach state." - input: CreateInstanceAlarmInput! - ): AlarmPayload @inputs(name: "createInstanceAlarm", schema: "\/graphql\/v2\/inputs\/createInstanceAlarm.json", ui: "\/graphql\/v2\/inputs\/createInstanceAlarm.ui.json") - - """ - Update a registered alarm's mutable fields. - - Omit a field from the input to leave it unchanged. - - Requires `environment:update` on the alarm's environment. - """ - updateInstanceAlarm( - "Your organization's unique identifier." - organizationId: ID! - - "The alarm's unique identifier." - id: UUID! - - "Update a registered alarm's mutable fields. Omit a field to leave it unchanged." - input: UpdateInstanceAlarmInput! - ): AlarmPayload @inputs(name: "updateInstanceAlarm", schema: "\/graphql\/v2\/inputs\/updateInstanceAlarm.json", ui: "\/graphql\/v2\/inputs\/updateInstanceAlarm.ui.json") - - """ - Delete an alarm registration. - - Removes the alarm and any recorded state transitions. The underlying cloud - provider alarm is unaffected. - - Requires `environment:update` on the alarm's environment. - """ - deleteInstanceAlarm( - "Your organization's unique identifier." - organizationId: ID! - - "The alarm's unique identifier." - id: UUID! - ): AlarmPayload - - """ - Create a new (empty) OCI repository in your organization's catalog. - - The `id` becomes the repository's permanent name and cannot be changed - after creation. Repositories must exist before any version can be - published — pushing to a non-existent repository returns 404. - """ - createOciRepo( - "Your organization's unique identifier." - organizationId: ID! - - "Create a new OCI repository in your organization's catalog. Repositories must exist before any version can be published to them." - input: CreateOciRepoInput! - ): OciRepoPayload @inputs(name: "createOciRepo", schema: "\/graphql\/v2\/inputs\/createOciRepo.json", ui: "\/graphql\/v2\/inputs\/createOciRepo.ui.json") - - "Update an OCI repository's mutable metadata (today: attributes)." - updateOciRepo( - "Your organization's unique identifier." - organizationId: ID! - - "The repository name (e.g., `aws-aurora-postgres`)." - id: ID! - - "Update an OCI repository's user-settable metadata. The repository name and artifact type are immutable." - input: UpdateOciRepoInput! - ): OciRepoPayload @inputs(name: "updateOciRepo", schema: "\/graphql\/v2\/inputs\/updateOciRepo.json", ui: "\/graphql\/v2\/inputs\/updateOciRepo.ui.json") - - """ - Delete an OCI repository. - - Refused if the repository has any published versions. Tags are immutable; - to remove versions today, recreate the repository. - """ - deleteOciRepo( - "Your organization's unique identifier." - organizationId: ID! - - "The repository name to delete." - id: ID! - ): OciRepoPayload - - """ - Share an OCI repo with recipient projects matching `recipientConditions`. - - The caller must have `repo:update` on the source repo. Grants are immutable — - to change `action` or `recipientConditions`, delete and re-create. - """ - createRepoGrant( - "Your organization's unique identifier." - organizationId: ID! - - "The repository name (e.g., `aws-aurora-postgres`)." - repoId: ID! - - "Share an OCI repo with recipient projects matching attribute conditions. The caller must have `repo:update` on the source repo." - input: CreateRepoGrantInput! - ): GrantPayload @inputs(name: "createRepoGrant", schema: "\/graphql\/v2\/inputs\/createRepoGrant.json", ui: "\/graphql\/v2\/inputs\/createRepoGrant.ui.json") - - """ - Share a resource with recipient environments matching `recipientConditions`. - - The caller must have `resource:update` on the source resource. Grants are - immutable — to change `action` or `recipientConditions`, delete and re-create. - """ - createResourceGrant( - "Your organization's unique identifier." - organizationId: ID! - - "The resource's unique identifier." - resourceId: ID! - - "Share a resource with recipient environments matching attribute conditions. The caller must have `resource:update` on the source resource." - input: CreateResourceGrantInput! - ): GrantPayload @inputs(name: "createResourceGrant", schema: "\/graphql\/v2\/inputs\/createResourceGrant.json", ui: "\/graphql\/v2\/inputs\/createResourceGrant.ui.json") - - """ - Delete a grant by its id. The caller must have `repo:update` on the grant's - source repo (for repo grants) or `resource:update` on its source resource - (for resource grants). - """ - deleteGrant( - "Your organization's unique identifier." - organizationId: ID! - - "The grant's unique identifier." - id: UUID! - ): GrantPayload -} - -""" -The decision returned by an `evaluatePolicy` request. - -`action` and `entityId` mirror the inputs so batch callers can correlate -decisions with their original questions without tracking positions -externally. -""" -type PolicyDecision { - "`true` if the subject is permitted to perform the action; `false` otherwise." - allowed: Boolean! - - "The action that was evaluated." - action: String! - - "The identifier of the entity the action was evaluated against, echoed from the request." - entityId: ID! -} - -"A single permission question inside an `evaluatePolicies` request." -input PolicyDecisionInput { - "Action id in `entity:verb` form (for example `project:view`). Query `policyActions` for the catalog." - action: String! - - "The identifier of the entity (e.g., a project's identifier)." - entityId: ID! -} - -"Available fields for sorting the event type catalog." -enum EventTypesSortField { - "Sort alphabetically by event id (e.g., `deployment.completed`, `project.created`)." - ID -} - -""" -Filter criteria for narrowing the event type catalog. - -All filters are combined with AND logic. Omit a filter to not restrict on -that field. -""" -input EventTypesFilter { - "Filter by the entity the event applies to (e.g., `\"environment\"`, `\"deployment\"`). Use this to scope a subscription to a specific part of the platform." - entity: StringFilter -} - -"Sort order for the event type catalog." -input EventTypesSort { - "The field to sort by." - field: EventTypesSortField! - - "`ASC` for A-Z, `DESC` for Z-A." - order: SortOrder! -} - -""" -A single event type in the Massdriver event catalog. - -The `id` field follows dotted `entity.action` notation in past tense -(e.g., `project.cloned`, `deployment.completed`). -""" -type EventType { - "Dotted, past-tense event id, e.g., `\"environment.deployed\"` or `\"deployment.completed\"`." - id: ID! - - "Human-readable summary of when this event fires and what its payload contains." - description: String! - - "The entity this event applies to (e.g., `\"environment\"`, `\"deployment\"`, `\"instance\"`)." - entity: String! - - "Past-tense verb describing what happened to the entity (e.g., `\"created\"`, `\"deployed\"`). Distinct from `PolicyAction.verb`, which is present-tense and describes what a principal may do." - action: String! - - "How the entity was modified. Consumers use this for cache invalidation — `CREATED` \/ `UPDATED` \/ `DELETED` maps cleanly onto an in-memory cache's add \/ replace \/ remove." - changeType: EventAction! -} - -""" -The type of lifecycle change that triggered an event. - -Every event carries an action indicating whether the resource was created, -modified, or removed. -""" -enum EventAction { - "A new resource was created." - CREATED - - "An existing resource was modified (configuration, status, or metadata changed)." - UPDATED - - "A resource was permanently removed." - DELETED -} - -""" -Base interface implemented by all lifecycle events. - -Every event carries an `action` describing what happened and a `timestamp` recording -when it occurred. Concrete event types add a field for the affected resource. - -Because each subscription returns a union, use an `... on Event` fragment to -access the shared fields and concrete fragments for the resource payload: - -```graphql -subscription { - projectEvents(organizationId: "my-org", projectId: "my-project") { - ... on Event { action timestamp } - ... on ComponentEvent { - component { id name } - } - ... on LinkEvent { - link { id fromField toField } - } - } -} -``` -""" -interface Event { - "The type of change that occurred." - action: EventAction! - - "When the event occurred, in UTC." - timestamp: DateTime! -} - -""" -A lifecycle event for a project. - -Emitted when a project is created, updated (renamed, settings changed), or deleted. -Subscribe via `organizationEvents` or `projectEvents`. -""" -type ProjectEvent implements Event { - action: EventAction! - - timestamp: DateTime! - - "The project that was created, updated, or deleted." - project: Project! -} - -""" -A lifecycle event for an environment. - -Emitted when an environment is created, updated (renamed, settings changed), or deleted -within a project. Subscribe via `projectEvents` for any environment in the project, or -via `environmentEvents` for a specific environment (updates and deletes only — the -environment must exist to subscribe to it). -""" -type EnvironmentEvent implements Event { - action: EventAction! - - timestamp: DateTime! - - "The environment that was created, updated, or deleted." - environment: Environment! -} - -""" -A lifecycle event for an environment default — a resource pre-assigned to an -environment so instances inherit it automatically when their connection schema -matches the resource type. - -Fires `CREATED` when `setEnvironmentDefault` adds a default and `DELETED` when -`removeEnvironmentDefault` clears one. Defaults are exposed as a paginated -`environment.defaults` connection, so refetch that page on receipt rather than -relying on cache merging — the parent `Environment` row is unchanged. -Subscribe via `environmentEvents`. -""" -type EnvironmentDefaultEvent implements Event { - action: EventAction! - - timestamp: DateTime! - - "The environment default that was created or deleted." - environmentDefault: EnvironmentDefault! -} - -""" -A lifecycle event for an instance. - -Emitted when an instance is created, updated (configuration changed, deployment triggered), -or deleted. Subscribe via `instanceEvents` or `projectEvents`. -""" -type InstanceEvent implements Event { - action: EventAction! - - timestamp: DateTime! - - "The instance that was created, updated, or deleted." - instance: Instance! -} - -""" -A lifecycle event for a component. - -Emitted when a component is added to a blueprint, moved on the canvas, renamed or -re-tagged, or removed. Subscribe via `projectEvents`. -""" -type ComponentEvent implements Event { - action: EventAction! - - timestamp: DateTime! - - "The component that was created, updated, or deleted." - component: Component! -} - -""" -A lifecycle event for a blueprint link between two components. - -Emitted when components are linked or unlinked in a project's blueprint. Links have -no mutable body, so only `CREATED` and `DELETED` actions are emitted. Subscribe via -`projectEvents`. -""" -type LinkEvent implements Event { - action: EventAction! - - timestamp: DateTime! - - "The link that was created or deleted." - link: Link! -} - -""" -A lifecycle event for a realized connection between two instances in an environment. - -A connection is the runtime materialization of a blueprint link — it wires one -instance's output artifact into another instance's input field within a single -environment. Emitted when a connection is established or torn down. Subscribe via -`environmentEvents` for every connection in an environment, or via `instanceEvents` -for connections that terminate on a specific instance. -""" -type ConnectionEvent implements Event { - action: EventAction! - - timestamp: DateTime! - - "The connection that was created or deleted." - connection: Connection! -} - -""" -A lifecycle event for a cloud metric alarm attached to an instance. - -Emitted when an alarm is registered, reconfigured, removed, or when its -firing state changes (e.g., `OK` → `ALARM`). State transitions surface as -`UPDATED` — read the latest status from `alarm.currentState`. Subscribe via -`environmentEvents` or `instanceEvents`. -""" -type AlarmEvent implements Event { - action: EventAction! - - timestamp: DateTime! - - "The alarm that was created, updated, or deleted." - alarm: Alarm! -} - -""" -A lifecycle event for an OCI repository in the organization's bundle catalog. - -Emitted with action `CREATED` the first time a bundle is published under a -new repository name. The registry is immutable — repositories cannot be -renamed or deleted through the API today — so subsequent publishes to an -existing repository fire a `BundleEvent` instead. Subscribe via -`organizationEvents`. -""" -type OciRepoEvent implements Event { - action: EventAction! - - timestamp: DateTime! - - "The OCI repository that was created." - ociRepo: OciRepo! -} - -""" -A lifecycle event for a deployment. - -Emitted when a deployment is created (`CREATED`) and each time its status -transitions (`UPDATED`) — e.g., `PENDING` → `RUNNING` → `COMPLETED`. Log -deltas are **not** carried on this event; subscribe to `deploymentLogs` for -streaming log content. - -Subscribe via `deploymentEvents` for a single deployment's feed, or via -`instanceEvents` / `environmentEvents` to see every deployment in an -instance or environment. - -On `UPDATED`, re-select whichever fields you need (typically `status` and -`elapsedTime`) — Apollo will merge the payload into its cache by `Deployment:id`. -""" -type DeploymentEvent implements Event { - action: EventAction! - - timestamp: DateTime! - - "The deployment that was created or updated." - deployment: Deployment! -} - -""" -A lifecycle event for a published bundle version. - -Emitted with action `CREATED` each time a new bundle release is pushed to -an OCI repository in the organization. Bundle versions are immutable, so -neither `UPDATED` nor `DELETED` is emitted today. Subscribe via -`organizationEvents`. -""" -type BundleEvent implements Event { - action: EventAction! - - timestamp: DateTime! - - "The bundle version that was published." - bundle: Bundle! -} - -"Events delivered by the `organizationEvents` subscription." -union OrganizationEventsPayload = ProjectEvent | OciRepoEvent | BundleEvent - -""" -Events delivered by the `projectEvents` subscription. - -Covers the project itself plus its blueprint (components, links) and the -environments provisioned from it. -""" -union ProjectEventsPayload = ProjectEvent | EnvironmentEvent | ComponentEvent | LinkEvent - -""" -Events delivered by the `environmentEvents` subscription. - -Covers the environment itself, its default resources, every instance -provisioned into it, every connection wired within it, every alarm attached -to those instances, and every deployment run against them. -""" -union EnvironmentEventsPayload = EnvironmentEvent | EnvironmentDefaultEvent | InstanceEvent | ConnectionEvent | AlarmEvent | DeploymentEvent - -""" -Events delivered by the `instanceEvents` subscription. - -Covers the instance itself, incoming connections that terminate on it, -alarms attached to it, and deployments run against it. -""" -union InstanceEventsPayload = InstanceEvent | ConnectionEvent | AlarmEvent | DeploymentEvent - -""" -Events delivered by the `deploymentEvents` subscription. - -A single-deployment feed. Fires on creation and every status transition. -For log content, subscribe to `deploymentLogs` — log deltas don't travel on -this feed. -""" -union DeploymentEventsPayload = DeploymentEvent - -""" -The thing a grant is sharing — exactly one of an OCI repo or a resource. The -caller authoring the grant must have `repo:update` (for repo grants) or -`resource:update` (for resource grants) on this source. -""" -union GrantSource = OciRepo | Resource - -""" -A grant: a specific OCI repo or resource shared with recipient projects / -environments matching `recipientConditions`. -""" -type Grant { - "Unique identifier for this grant." - id: ID! - - "The action being granted on the source. Repo grants take `repo:view`\/`repo:pull`\/`repo:push`; resource grants take `resource:view`\/`resource:export`." - action: String! - - "Either `\"*\"` (the grant is a wildcard — every recipient in the org sees the source) or a JSON-encoded object of attribute conditions the recipient project \/ environment must satisfy. Keys are attribute names; values are a string or list of strings." - recipientConditions: Conditions! - - "The OCI repo or resource being shared." - source: GrantSource! - - "When this grant was created." - createdAt: DateTime! - - "When this grant was last updated." - updatedAt: DateTime! -} - -type GrantPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: Grant -} - -type GrantsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type grant." - items: [Grant] -} - -"Share an OCI repo with recipient projects matching attribute conditions. The caller must have `repo:update` on the source repo." -input CreateRepoGrantInput { - "The action being granted on the repo. Currently the only grantable repo action is `repo:pull` — repo visibility is inferred from any granted action, and publishing is not a sharing concern." - action: String! - - "Restrict this grant to recipient projects whose attributes match every condition. Send the literal `\"*\"` to apply to every project in the organization. Per-key, send `\"*\"` to match any value or a non-empty list of strings to match a closed set." - recipientConditions: Conditions! -} - -"Share a resource with recipient environments matching attribute conditions. The caller must have `resource:update` on the source resource." -input CreateResourceGrantInput { - "The action being granted on the resource. Currently the only grantable resource action is `resource:export` — resource visibility is inferred from any granted action." - action: String! - - "Restrict this grant to recipient environments whose attributes match every condition. Send the literal `\"*\"` to apply to every environment in the organization. Per-key, send `\"*\"` to match any value or a non-empty list of strings to match a closed set." - recipientConditions: Conditions! -} - -""" -Whether a policy grants or blocks its actions. - -`DENY` always wins over `ALLOW` — if any deny policy matches a resource, the -action is blocked even if other allow policies also match. -""" -enum PolicyEffect { - "Grant the policy's actions when the conditions match." - ALLOW - - "Block the policy's actions when the conditions match. Deny policies override every matching allow policy." - DENY -} - -"Fields available for sorting a list of policies." -enum PoliciesSortField { - "Sort by creation date (oldest first or newest first)." - CREATED_AT -} - -""" -A kind of thing an ABAC policy can be attached to — a *project*, an -*environment*, a *resource*, and so on. - -Every action in the policy catalog acts on exactly one entity, encoded as the -prefix of the action id (`project` in `project:view`). Use `policyEntities` -to enumerate every entity with its description when building a -policy-authoring UI. -""" -type PolicyEntity { - "Stable identifier for the entity, matching the prefix of action ids (for example `project`, `environment`, `resource_type`)." - id: ID! - - "Human-readable description of what this entity represents in Massdriver." - description: String! -} - -""" -A single action a policy can allow or deny. Actions follow the format -`{entity}:{verb}` — for example `project:view` or `instance:deploy`. - -Use `policyActions` to enumerate the complete catalog when building a -policy-authoring UI. Descriptions are written for end users and explain what -the action permits, what it does **not** permit, and how it relates to other -actions (especially around the project-as-view-boundary model). -""" -type PolicyAction { - "Canonical action identifier in `{entity}:{verb}` format (for example `project:view`)." - id: ID! - - "Action verb portion of the id (for example `view`, `deploy`, `configure`)." - verb: String! - - "The entity this action applies to." - entity: PolicyEntity! - - "Human-readable description of what this action permits." - description: String! -} - -""" -A single ABAC group policy: an effect (`ALLOW`/`DENY`), one or more actions, -optional attribute conditions, and the group whose members the policy -applies to. - -Conditions are evaluated AND within a policy and OR across policies on the -same group. A policy with no conditions is a wildcard — it matches any -resource of each action's entity. Deny policies win over allow policies. A -policy can list actions across different entities (for example -`project:view` together with `instance:deploy`); for each action, condition -keys whose registered attribute scope is unreachable for that action's -entity are skipped, and a policy whose conditions all skip for a given -action is a wildcard match for that action. See `docs/guides/abac.md` for -the full evaluation model. -""" -type Policy { - "Unique identifier for this policy." - id: ID! - - "Whether this policy grants (`ALLOW`) or blocks (`DENY`) the actions." - effect: PolicyEffect! - - "The actions this policy authorizes, each in `{entity}:{verb}` form (for example `[\"repo:pull\", \"instance:deploy\"]`). Always non-empty." - actions: [String!]! - - "Either `\"*\"` (the policy is a wildcard — every resource of the entity matches) or a JSON-encoded object of attribute conditions. Keys are attribute names; values are a string or list of strings." - conditions: Conditions! - - "The group this policy applies to." - group: Group! - - "When this policy was created." - createdAt: DateTime! - - "When this policy was last updated." - updatedAt: DateTime! -} - -type PoliciesPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type policy." - items: [Policy] -} - -type PolicyPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: Policy -} - -"Sorting options for a list of policies. Specify a field and direction." -input PoliciesSort { - "The field to sort by." - field: PoliciesSortField! - - "`ASC` for oldest first, `DESC` for newest first." - order: SortOrder! -} - -"Filters for narrowing a list of policies. All filters are optional and combine with AND logic." -input PoliciesFilter { - "Filter by `ALLOW` or `DENY`. Use `in` to match either." - effect: PolicyEffectFilter - - "Filter by action id (e.g. `project:view`, `instance:deploy`). Returns policies whose `actions` list contains the value. Use `in` to return policies whose `actions` list intersects the supplied list." - action: StringFilter -} - -"Filter by `ALLOW` or `DENY`." -input PolicyEffectFilter { - "Return only policies whose effect exactly equals this value." - eq: PolicyEffect - - "Return policies whose effect matches any value in this list." - in: [PolicyEffect!] -} - -"Attach an ABAC policy to a group. Each policy grants or denies one or more actions on entities whose attributes satisfy the conditions." -input CreateGroupPolicyInput { - "What members of this group can do. Pass one or more action ids in `{entity}:{verb}` form. Conditions apply to every action; actions whose entity does not support a given condition simply never match. Duplicate entries are rejected." - actions: [String!]! - - "Restrict the actions to entities whose attributes match every condition. Send the literal `\"*\"` to apply to all entities of each action's type. Per-key, send `\"*\"` to match any value or a non-empty list of strings to match a closed set. Within a policy all conditions are AND. Across policies, evaluation is OR." - conditions: Conditions! - - "ALLOW grants the actions. DENY blocks them and wins over any matching ALLOW." - effect: PolicyEffect! -} - -"Edit an existing policy in place. The principal cannot be changed." -input UpdatePolicyInput { - "Replace the policy's full action list. Pass one or more action ids in `{entity}:{verb}` form. Omit the field to leave the existing list unchanged. Duplicate entries are rejected." - actions: [String!] - - "Restrict the actions to entities whose attributes match every condition. Pass `\"*\"` to clear all conditions and make the policy a wildcard. Per-key, send `\"*\"` to match any value or a non-empty list of strings to match a closed set. Omit the field to leave conditions unchanged." - conditions: Conditions - - "ALLOW grants the actions. DENY blocks them and wins over any matching ALLOW." - effect: PolicyEffect -} - -""" -How a resource was created, which determines how it can be managed. - -- **IMPORTED** resources are created and managed directly through the API. - You can update their name, payload, and delete them at any time. -- **PROVISIONED** resources are created automatically when an instance is deployed. - They are managed by their owning instance and cannot be modified or deleted through the API. -""" -enum ResourceOrigin { - "Created manually via the API. Can be updated and deleted directly." - IMPORTED - - "Created automatically by deploying an instance. Managed by the owning instance's lifecycle." - PROVISIONED -} - -"Available fields for sorting the resources list." -enum ResourcesSortField { - "Sort alphabetically by resource name (A-Z when ascending)." - NAME - - "Sort by creation timestamp (oldest first when ascending, newest first when descending)." - CREATED_AT -} - -""" -Controls the sort order of the resources list. - -Defaults to alphabetical by name (ascending) when not specified. -""" -input ResourcesSort { - "The field to sort by." - field: ResourcesSortField! - - "Ascending or descending order." - order: SortOrder! -} - -""" -Narrows the resources list to only matching records. - -All filters are combined with AND logic. Omit a filter to skip that criterion. -""" -input ResourcesFilter { - "Return only resources with the specified origin (IMPORTED or PROVISIONED)." - origin: ResourceOriginFilter - - "Return only resources of the given resource type, matched by the type's identifier (e.g., `aws-iam-role`, `kubernetes-cluster`)." - resourceType: StringFilter - - "Return only resources provisioned into the specified environment(s). Imported resources have no environment and are excluded when this filter is set." - environmentId: IdFilter - - "Full-text search across the resource name. Results are ranked by relevance unless you provide an explicit `sort`. For terms longer than 3 characters, name-prefix matches are also included. **Note:** pagination cursors returned by search results use offset-based pagination and are not interchangeable with cursors from non-search queries." - search: String -} - -""" -Filter resources by their origin. - -Provide either `eq` for an exact match or `in` for matching any of several origins. -""" -input ResourceOriginFilter { - "Return resources with exactly this origin." - eq: ResourceOrigin - - "Return resources matching any of the listed origins." - in: [ResourceOrigin!] -} - -"Import a new resource with a name and optional payload conforming to the resource type's schema." -input CreateResourceInput { - "A human-readable name for this resource" - name: String! - - "Resource data conforming to the resource type's schema. Structure varies by type." - payload: Map -} - -"Update a resource's name or payload. Provisioned resources can only have their name updated. Imported resources can also update their payload." -input UpdateResourceInput { - "A new human-readable name for this resource" - name: String - - "Updated resource data. Only applicable to imported resources." - payload: Map -} - -""" -A cloud credential, database connection string, network configuration, or other -infrastructure output produced by (or imported into) Massdriver. - -Resources are the connective tissue between instances. When an instance is deployed, it -produces resources as outputs. Other instances can consume those resources as inputs, -creating a dependency graph of your infrastructure. - -Resources have two origins: -- **Imported** — created directly through the API (e.g., uploading existing AWS credentials). - You have full CRUD control over these resources. -- **Provisioned** — created automatically when an instance is deployed. These are read-only - and managed entirely by the owning instance's lifecycle. -""" -type Resource { - "Unique identifier for this resource." - id: ID! - - "Human-readable display name for this resource." - name: String! - - """ - The resource type identifier (e.g., `aws-iam-role`). - - **Deprecated:** Use `resourceType { id }` instead for a richer representation - that includes the resource type's name and schema. - """ - type: String - - "How this resource was created. Determines whether it can be modified through the API." - origin: ResourceOrigin! - - "The resource type that this resource conforms to, defining its schema and validation rules." - resourceType: ResourceType - - """ - The bundle output handle that produced this resource (e.g., `authentication`, `database`). - - Set only for **provisioned** resources — it corresponds to a field declared under - `artifacts` in the producing bundle's `massdriver.yaml`. Null for **imported** resources. - """ - field: String - - """ - The instance whose deployment produced this resource. - - Null for **imported** resources. For **provisioned** resources, this is the instance - that owns the resource's lifecycle — updating or decommissioning the instance will - update or remove the resource. - """ - instance: Instance - - """ - Download formats supported for this resource. - - Always includes `json` (the raw payload). Additional formats come from the resource - type's `$md.export` declarations, which can render the payload as YAML or other - templated outputs. Pass a returned format to `downloadArtifact` to retrieve the - rendered content. - """ - formats: [String!]! - - """ - The resource's structured payload. Fields marked `$md.sensitive` in the resource type's - schema are masked as `[SENSITIVE]`. Use `exportResource` to retrieve an unmasked copy — - that operation is recorded in the audit log. - """ - payload: Map - - """ - Key-value attributes assigned directly to this resource, used by ABAC - policies. Reserved keys starting with `md-` are auto-injected by the system - and excluded from this map — see `effectiveAttributes` for the merged view. - """ - attributes: Map! - - """ - The full set of attributes that ABAC policy evaluation sees for this - resource — its own attributes plus user attributes cascaded from the - instance chain (project, environment, component, instance) and the - resource type, plus auto-injected `md-*` system attributes. - """ - effectiveAttributes: Map! - - "When this resource was created (UTC)." - createdAt: DateTime! - - "When this resource was last modified (UTC)." - updatedAt: DateTime! - - """ - Connections that consume this resource — runtime wirings into a sibling instance via a - blueprint Link. Filtered to consumers in projects you can view. - """ - connections( - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): ConnectionsPage - - """ - Environments that have set this resource as the default for its resource type. Filtered - to environments in projects you can view. - """ - environmentDefaults( - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): EnvironmentDefaultsPage - - """ - Per-instance connection-slot overrides that point at this resource. Filtered to instances - in projects you can view. - """ - remoteReferences( - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): RemoteReferencesPage - - """ - Grants the publisher has authored on this resource — what it is shared as, and which - recipient projects / environments qualify. If you can see this resource you can see all - of its grants; grants are publisher-side metadata, not visibility-gated themselves. - """ - grants( - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): GrantsPage -} - -""" -A resource with its sensitive payload values revealed, returned by the `exportResource` mutation. - -Unlike the regular `Resource` type — where fields marked `$md.sensitive` in the resource -type's schema are masked — this type exposes the raw values so they can be consumed by -automation or copied into downstream systems. Requesting this type is recorded in the -audit log. -""" -type ResourceWithSensitiveValues { - "Unique identifier for this resource." - id: ID! - - "Human-readable display name for this resource." - name: String! - - "How this resource was created." - origin: ResourceOrigin! - - "The resource type that this resource conforms to, defining its schema and validation rules." - resourceType: ResourceType - - """ - The resource's payload with `$md.sensitive` fields unmasked. The shape is defined by - the resource type's schema. - """ - payload: Map - - """ - The resource rendered in the requested `format`. For `json` this is a stringified JSON - document of the payload; for resource-type-specific formats (e.g. `yaml`, `env`) this is - the template output defined by the resource type's schema. - """ - rendered: String! - - "When this resource was created (UTC)." - createdAt: DateTime! - - "When this resource was last modified (UTC)." - updatedAt: DateTime! -} - -type ResourcesPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type resource." - items: [Resource] -} - -type ResourcePayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: Resource -} - -type ResourceWithSensitiveValuesPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: ResourceWithSensitiveValues -} - -"Fields available for ordering the resource types list." -enum ResourceTypesSortField { - "Alphabetical order by the resource type's display name." - NAME - - "Chronological order by the date the resource type was created." - CREATED_AT -} - -"Controls the sort order of the resource types list." -input ResourceTypesSort { - "The field to order results by." - field: ResourceTypesSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -"Upsert a resource type for your organization from a JSON Schema document. If an existing resource type has the same identifier, its schema is replaced. **Deprecated:** this mutation exists solely to bridge V0 `publishArtifactDefinition` into the V2 API while resource types are being migrated to OCI. New integrations should use the OCI-native publishing flow — this mutation may be removed without notice." -input PublishResourceTypeInput { - "The full JSON Schema document describing the shape of data this resource type exposes to dependents. Must include `$md.name` (a kebab-case identifier like `aws-iam-role`) and should include `$md.label`, `$md.icon`, and `$md.ui.connectionOrientation`." - schema: Map! -} - -"Filters for narrowing the resource types list." -input ResourceTypesFilter { - "Filter by resource type identifier (e.g., `aws-iam-role`, `kubernetes-cluster`)." - id: StringFilter - - "Full-text search across the resource type's display name and identifier (e.g., `\"iam\"` matches `AWS IAM Role` \/ `aws-iam-role`). Results are ranked by relevance unless you provide an explicit `sort`. For terms longer than 3 characters, identifier-prefix matches are also included. **Note:** pagination cursors returned by search results use offset-based pagination and are not interchangeable with cursors from non-search queries." - search: String -} - -""" -Determines how instances receive a dependency of this resource type. - -When a bundle declares a dependency, the connection orientation of the -dependency's resource type controls how it gets satisfied at deploy time. -""" -enum ConnectionOrientation { - "The dependency is wired explicitly by drawing a connection between two instances on the canvas. The user chooses which specific instance provides the resource." - LINK - - "The dependency is satisfied automatically by an environment-level default. The resource is shared across all instances in the environment without explicit wiring." - ENVIRONMENT_DEFAULT -} - -""" -A single set of import instructions for a resource type, typically rendered as a tab. - -Resource types may ship multiple instruction variants (e.g., one for the CLI and one -for the cloud console) so users can pick the workflow they prefer when importing an -existing resource. The `label` is the tab heading; the `content` is the markdown body. -""" -type ImportInstruction { - "Short heading shown above this instruction set (e.g., \"AWS CLI\", \"AWS Console\")." - label: String! - - "Markdown body of the instructions. Already decoded from any base64 transport encoding." - content: String! -} - -""" -A resource type that defines what kind of infrastructure a resource represents. - -Resource types are the schema layer for Massdriver's connection system. Every -dependency a bundle declares and every resource a bundle produces references a -resource type. This is what makes bundles composable -- a database bundle that -produces an `aws-rds-instance` resource can be connected to any application -bundle that declares an `aws-rds-instance` dependency. - -Resource types include both public types provided by Massdriver (e.g., -`aws-iam-role`, `kubernetes-cluster`) and private types defined by your -organization for custom infrastructure. -""" -type ResourceType { - "Unique identifier in kebab-case (e.g., `aws-iam-role`, `kubernetes-cluster`)." - id: ID! - - "Human-readable display name (e.g., \"AWS IAM Role\", \"Kubernetes Cluster\")." - name: String! - - "URL to the icon representing this resource type, if available." - icon: String - - "How instances receive a dependency of this resource type. Determines whether connections are explicit links on the canvas or automatic environment-level defaults." - connectionOrientation: ConnectionOrientation! - - """ - The full JSON Schema describing the shape of data this resource type exposes to dependents. - - Use this to generate forms, validate inputs, or inspect the fields available on a connection - of this resource type. The schema is returned verbatim, including Massdriver's `$md` extensions - (e.g., `icon`, `ui`). Callers that only want the data contract can read `properties.data` or - strip `$md` themselves. - """ - schema: Map! - - """ - UI hints describing how to render the import form for this resource type. - - Follows [react-jsonschema-form](https://rjsf-team.github.io/react-jsonschema-form/)'s - `uiSchema` conventions: keys mirror the `data` schema's structure and values contain - rendering directives (e.g., `ui:widget`, `ui:order`, `ui:help`). Returns an empty - object when the resource type does not provide UI hints. - """ - uiSchema: Map! - - """ - Step-by-step import instructions, typically one entry per workflow (CLI, console, etc.). - - Each entry is rendered as its own tab or section so users can pick the workflow they - prefer when importing an existing resource. Returns an empty list when the resource - type does not provide instructions. - """ - instructions: [ImportInstruction!]! - - """ - The auto-injected `md-*` system attributes for this resource type - (today: `md-id`). Resource types do not yet carry user-settable - attributes; user attributes will arrive when resource types move to - OCI-hosted distribution. - """ - effectiveAttributes: Map! - - "Timestamp when this resource type was created (UTC)." - createdAt: DateTime! - - "Timestamp when this resource type was last modified (UTC)." - updatedAt: DateTime! -} - -type ResourceTypesPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type resource_type." - items: [ResourceType] -} - -type ResourceTypePayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: ResourceType -} - -"Link an instance's resource field to a resource from another project or an imported resource. The instance must not be in a provisioned or failed state." -input SetRemoteReferenceInput { - "The resource field to assign the reference to" - field: String! -} - -"Remove a remote reference from an instance. The reference can only be removed if no provisioned instances are connected through it." -input RemoveRemoteReferenceInput { - "The resource field to remove the reference from" - field: String! -} - -""" -A per-instance override of a single connection slot. The blueprint Link wires -a slot from a sibling package's output; a remote reference overrides that -wiring on one instance, pointing the slot at a resource from another project -(or an imported resource) instead. - -Remote references enable cross-project infrastructure sharing. For example, a -networking team provisions a VPC in one project, and application teams override -the `vpc` connection slot on their database/cache/etc. instances to point at -that shared VPC. - -Each remote reference binds a specific `field` on the instance — a key in the -instance's bundle's `connectionsSchema` — to the target resource. The override -takes priority over any blueprint-level Link on the same slot, and reverts to -the Link (or environment default) when removed. -""" -type RemoteReference { - "Unique identifier for this remote reference." - id: ID! - - "The name of the resource field on the instance that this reference satisfies (e.g., `aws_authentication` or `vpc`)." - field: String! - - "When this remote reference was created (UTC)." - createdAt: DateTime! - - "When this remote reference was last modified (UTC)." - updatedAt: DateTime! - - "The resource from another project (or an imported resource) that this reference points to." - resource: Resource! - - "The instance whose connection slot this remote reference overrides." - instance: Instance! -} - -type RemoteReferencePayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: RemoteReference -} - -type RemoteReferencesPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type remote_reference." - items: [RemoteReference] -} - -""" -A non-human identity for programmatic API access. - -Service accounts let you integrate CI/CD pipelines, scripts, and external tools with the -Massdriver API. They authenticate using access tokens — issue one with `createAccessToken` -and pass it as a bearer token on API requests. - -**Permissions** — Service accounts have no inherent permissions. Add them to groups to grant -access, using the same role model as human users. A service account in the `Admins` group -has the same access as a human admin. - -**Lifecycle:** -1. Create the service account. -2. Add the service account to one or more groups to grant access. -3. Issue one or more access tokens for the service account via `createAccessToken`. The raw - token value is only shown once at creation — store it securely before navigating away. -4. Delete the service account when it is no longer needed. This immediately revokes all access, - including any active access tokens. -""" -type ServiceAccount { - "Unique identifier for this service account." - id: ID! - - "Human-readable name displayed in the UI, logs, and audit trail." - name: String! - - "Optional text explaining what this service account is used for." - description: String - - "When this service account was created (UTC)." - createdAt: DateTime! - - "When this service account was last modified (UTC)." - updatedAt: DateTime! -} - -""" -A service account returned from `createServiceAccount`, including the raw default access token. - -The `defaultAccessToken.token` value is the only opportunity to capture the bearer credential -for this service account — store it securely before navigating away. If lost, revoke the token -and issue a new one with `createAccessToken`. -""" -type ServiceAccountWithDefaultAccessToken { - "Unique identifier for this service account." - id: ID! - - "Human-readable name displayed in the UI, logs, and audit trail." - name: String! - - "Optional text explaining what this service account is used for." - description: String - - "When this service account was created (UTC)." - createdAt: DateTime! - - "When this service account was last modified (UTC)." - updatedAt: DateTime! - - "The default access token issued for this service account. The raw `token` value is only included in this response — store it securely." - defaultAccessToken: AccessTokenWithValue! -} - -type ServiceAccountsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type service_account." - items: [ServiceAccount] -} - -type ServiceAccountPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: ServiceAccount -} - -type ServiceAccountGroupPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: ServiceAccount -} - -type ServiceAccountWithDefaultAccessTokenPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: ServiceAccountWithDefaultAccessToken -} - -"Fields available for sorting the service accounts list." -enum ServiceAccountsSortField { - "Sort alphabetically by service account name (A-Z or Z-A)." - NAME - - "Sort by creation date (oldest first or newest first)." - CREATED_AT -} - -"Sorting options for the service accounts list. Specify a field and direction." -input ServiceAccountsSort { - "The field to sort by." - field: ServiceAccountsSortField! - - "Sort direction (`ASC` or `DESC`)." - order: SortOrder! -} - -""" -Filter criteria for narrowing which service accounts are returned. - -All filters are combined with AND logic — a service account must match every specified -filter to appear in the results. Omit a filter to not restrict on that field. -""" -input ServiceAccountsFilter { - """ - Case-insensitive substring search across the service account's `name` and `description`. - - Useful for finding a service account when you remember part of what it's called or what - it does but don't have its exact id. So `search: "deploy"` will match a service account - named `ci-deploy-bot`, one whose description mentions "deploys to staging", and any - other combination of substring matches in either field. - - Blank or whitespace-only values skip this filter. When `search` is active and no - explicit `sort` is provided, results are ranked by relevance — name-prefix matches - outrank tsvector-only matches in either field. - """ - search: String - - "Restrict results to one or more service accounts by their unique identifier." - id: IdFilter -} - -"Create a new service account for programmatic API access. Issues a default access token alongside the service account; the raw token value is returned once and cannot be retrieved later." -input CreateServiceAccountInput { - "How long the default access token issued alongside the service account remains valid, in minutes. The raw token value is returned once on creation and cannot be retrieved again. Capped at 5,256,000 minutes (10 years) by the service-account expiry policy." - defaultAccessTokenExpirationInMinutes: Int! - - "What this service account is used for" - description: String - - "A human-readable name for the service account" - name: String! -} - -"Update a service account's name or description. Both fields are optional; send only the fields you want to change." -input UpdateServiceAccountInput { - "What this service account is used for" - description: String - - "A human-readable name for the service account" - name: String -} - -""" -An access token with the raw token value included. - -This type is only returned by the `createAccessToken` mutation. The `token` field contains -the full credential needed for API authentication and **cannot be retrieved again** after -this response. Store it securely before navigating away. -""" -type AccessTokenWithValue { - "Unique identifier for this access token." - id: ID! - - "Human-readable label for identifying this token." - name: String! - - "The full bearer token value for API authentication. Only returned once at creation time — store it immediately in a secure location." - token: String! - - "A short, non-secret prefix (e.g., `md_a1b2c3d4`) used to identify this token in lists and logs without exposing the full value." - prefix: String! - - "Permission scopes that limit what this token can do." - scopes: [String!]! - - "When this token expires and stops working (UTC)." - expiresAt: DateTime! - - "When this token was created (UTC)." - createdAt: DateTime! -} - -""" -An access token's metadata, without the raw token value. - -Use this type to list, inspect, and manage existing tokens. The full token value is only -available at creation time — if you need to identify a specific token, use its `prefix` -(e.g., `md_a1b2c3d4`). - -**Token states:** -- **Active** — `revokedAt` is `null` and `expiresAt` is in the future. -- **Expired** — `expiresAt` is in the past. The token no longer works. -- **Revoked** — `revokedAt` is set. The token was manually disabled and no longer works. -""" -type AccessToken { - "Unique identifier for this access token." - id: ID! - - "Human-readable label for identifying this token." - name: String! - - "A short, non-secret prefix (e.g., `md_a1b2c3d4`) used to identify this token in lists and logs without exposing the full value." - prefix: String! - - "Permission scopes that limit what this token can do." - scopes: [String!]! - - "When this token expires and stops working (UTC)." - expiresAt: DateTime! - - "When this token was manually revoked, or `null` if still active." - revokedAt: DateTime - - "When this token was last used to authenticate an API request, or `null` if never used." - lastUsedAt: DateTime - - "When this token was created (UTC)." - createdAt: DateTime! -} - -type AccessTokenWithValuePayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: AccessTokenWithValue -} - -type AccessTokenPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: AccessToken -} - -type AccessTokensPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type access_token." - items: [AccessToken] -} - -"Fields by which the access tokens list can be sorted." -enum AccessTokensSortField { - "When the token was created." - CREATED_AT - - "When the token expires." - EXPIRES_AT -} - -"Sort options for the access tokens list." -input AccessTokensSort { - "Which field to sort by." - field: AccessTokensSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -""" -Filters for the access tokens list. - -Combine filters with AND logic. Omit a filter to not restrict on that dimension. -""" -input AccessTokensFilter { - "Narrow by status. `true` returns only revoked tokens, `false` returns only active tokens (not revoked and not expired), omit the field to return all tokens." - revoked: Boolean -} - -"Create a scoped, time-limited access token for the authenticated identity." -input CreateAccessTokenInput { - "How many minutes until this token expires. Defaults to 60 (1 hour). Maximum ~5,256,000 (10 years)." - expiresInMinutes: Int - - "A label to identify this token (e.g., 'CI deploy key')" - name: String! - - "Permission scopes. At least one required. Currently only [\"*\"] (full access) is supported." - scopes: [String!]! -} - -""" -The type of actor that performed an audit-logged action. - -Every audit log event records who (or what) triggered it. The actor type tells you -whether the action was performed by a human, an automated system, or a deployment process. -""" -enum AuditLogActorType { - "A human user authenticated with their personal account." - ACCOUNT - - "A service account authenticated via API key or access token." - SERVICE_ACCOUNT - - "An automated deployment process (e.g., Terraform apply)." - DEPLOYMENT - - "An internal system action with no specific user or service account. Also used for legacy events recorded before actor tracking was introduced." - SYSTEM -} - -"Available fields for sorting audit log events." -enum AuditLogsSortField { - "Sort by when the event occurred (oldest or newest first)." - OCCURRED_AT - - "Sort alphabetically by event type (e.g., `deployment.completed`, `project.created`)." - TYPE -} - -"Sorting options for the audit logs list." -input AuditLogsSort { - "The field to sort by." - field: AuditLogsSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -""" -Filter audit log events by the type of actor that performed the action. - -Provide either `eq` for an exact match or `in` for matching any of several actor types. -""" -input AuditLogActorTypeFilter { - "Match events where the actor type is exactly this value." - eq: AuditLogActorType - - "Match events where the actor type is any of these values." - in: [AuditLogActorType!] -} - -""" -Case-insensitive substring search across the actor's identifying fields. - -Useful for finding every action taken by a specific user or bot without needing to -know their exact ID. The search is matched against different fields depending on -actor type: - -- **Human users (`ACCOUNT`)** — email, first name, and last name. -- **Service accounts (`SERVICE_ACCOUNT`)** — service account name. -- **Deployments (`DEPLOYMENT`)** and **system actions (`SYSTEM`)** — the recorded actor ID. - -Matching is case-insensitive and uses substring semantics, so `search: "alice"` will -match `alice@example.com`, `Alice Wonderland`, and a service account named -`alice-deploy-bot`. -""" -input ActorSearchFilter { - "Substring to look for. Blank or missing values skip this filter." - search: String -} - -""" -Filter criteria for narrowing which audit log events are returned. - -All filters are combined with AND logic — an event must match every specified filter -to be included in the results. Omit a filter to not restrict on that field. -""" -input AuditLogsFilter { - "Filter by when the event occurred. Supports `eq` for a point in time, `gt`\/`gte` for lower bounds, and `lt`\/`lte` for upper bounds. Combine `gte` and `lte` for a range." - occurredAt: DatetimeFilter - - "Filter by event type using dot notation (e.g., `project.created`, `deployment.completed`). Supports `eq` for an exact match and `in` for matching any value in a list." - type: StringFilter - - "Filter by the type of actor that performed the action (e.g., only human users or only deployments)." - actorType: AuditLogActorTypeFilter - - "Filter by the specific actor's identifier. Matches across all actor types (account, service account, deployment)." - actorId: IdFilter - - "Search actors by a substring match across their identifying fields (email and name for users, name for service accounts, ID for deployments and system actions). Useful for finding every action taken by a person or bot without knowing their exact ID." - actor: ActorSearchFilter -} - -""" -The actor that performed an audit-logged action. - -Identifies who or what triggered the event. The `type` field tells you the category -of actor, and `name` provides a human-readable label: - -- **`ACCOUNT`** — `name` is the user's email address. -- **`SERVICE_ACCOUNT`** — `name` is the service account's name. -- **`DEPLOYMENT`** — `name` is a reference like `deployment:`. -- **`SYSTEM`** — `name` is `system` for internal actions. - -If the original actor has been deleted, the `name` will indicate this (e.g., "deleted account"). -""" -type AuditLogActor { - "Unique identifier of the actor that performed the action." - id: ID! - - "What kind of entity performed the action (human, service account, deployment, or system)." - type: AuditLogActorType! - - "Human-readable label for the actor. Email address for users, name for service accounts, or a reference identifier for deployments and system actions." - name: String! -} - -""" -An audit log event recording a significant action in your organization. - -Every state-changing operation in Massdriver is captured as an audit log event, following -the [CloudEvents](https://cloudevents.io/) specification. Use audit logs to track who -made changes, investigate incidents, and satisfy compliance requirements. - -**Event types** use dot notation to categorize actions (e.g., `project.created`, -`deployment.completed`, `group.member_added`). - -**Subjects** use MRI (Massdriver Resource Identifier) format to identify the affected -resource (e.g., `mri://organization/my-org/project/backend`). -""" -type AuditLog { - "Unique identifier for this audit log event." - id: ID! - - "When the event occurred (UTC). This is the authoritative timestamp for the action." - occurredAt: DateTime! - - "Event type in dot notation (e.g., `project.created`, `deployment.completed`)." - type: String! - - "The system component or service that generated this event." - source: String! - - "The affected resource in MRI format (e.g., `mri:\/\/organization\/my-org\/project\/backend`). May be `null` for organization-wide events." - subject: String - - "Event payload containing context-specific details about the action. The structure varies by event type." - data: Map - - "The actor (user, service account, deployment, or system) that performed this action." - actor: AuditLogActor! -} - -type AuditLogsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type audit_log." - items: [AuditLog] -} - -""" -The lifecycle status of an integration. - -Integrations transition through these states as they are activated or deactivated. -The `enabling` and `disabling` states are transient — the integration is performing -setup or teardown work and will settle into `enabled` or `disabled`. -""" -enum IntegrationStatus { - "Integration is inactive. No data is being collected or synced." - DISABLED - - "Integration is being deactivated. Teardown is in progress." - DISABLING - - "Integration is being activated. Setup is in progress." - ENABLING - - "Integration is active and running on its configured schedule." - ENABLED -} - -"Available fields for sorting the integrations list." -enum IntegrationsSortField { - "Sort by creation date." - CREATED_AT - - "Sort alphabetically by integration type identifier." - ID -} - -""" -Filter by integration status. - -All operators within a single filter are combined with **AND**. Use `in` to match -integrations in any of several statuses. - -```graphql -{ "status": { "in": ["enabled", "enabling"] } } -``` -""" -input IntegrationStatusFilter { - "Return only integrations whose status exactly equals this value." - eq: IntegrationStatus - - "Return integrations whose status matches any value in this list." - in: [IntegrationStatus!] -} - -""" -Filter options for the `integrations` query. - -All filters are combined with **AND**. Omit a filter to skip that constraint. -""" -input IntegrationsFilter { - "Filter by integration type identifier (e.g., `\"aws-cost-and-usage-reports\"`)." - id: StringFilter - - "Filter by the integration's current lifecycle status." - status: IntegrationStatusFilter -} - -""" -Sorting options for the `integrations` query. - -Specify the field to sort by and the direction. Defaults to `createdAt` ascending. -""" -input IntegrationsSort { - "The field to sort results by." - field: IntegrationsSortField! - - "`ASC` for A-Z \/ oldest first, `DESC` for Z-A \/ newest first." - order: SortOrder! -} - -"Create and activate an integration for your organization. The config and auth payloads must conform to the integration type's configSchema and authSchema respectively." -input CreateIntegrationInput { - "Authentication credentials. Must conform to the integration type's authSchema. Write-only — not returned in queries." - auth: Map! - - "Integration-specific configuration. Must conform to the integration type's configSchema." - config: Map! -} - -""" -A supported integration type from the Massdriver catalog. - -Integration types describe what external services can be connected and provide -the JSON schemas needed to configure them. Use the `configSchema` and `authSchema` -to build dynamic forms or validate input before calling `createIntegration`. -""" -type IntegrationTypeInfo { - "Unique identifier for this integration type (e.g., `\"aws-cost-and-usage-reports\"`)." - id: String! - - "Human-readable display name." - name: String! - - "Brief explanation of what this integration does and what data it provides." - description: String - - "URL to the full documentation for setting up this integration." - docs: String! - - "JSON Schema describing the structure of the `config` field when creating this integration. Use this to build configuration forms or validate input." - configSchema: Map! - - "JSON Schema describing the structure of the `auth` field when creating this integration. Typically contains credential requirements like IAM role ARNs or service account keys." - authSchema: Map! -} - -type IntegrationTypesPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type integration_type_info." - items: [IntegrationTypeInfo] -} - -""" -A configured integration connecting your organization to an external service. - -Each organization can have at most one integration per type. The integration's `id` -corresponds to the integration type (e.g., `"aws-cost-and-usage-reports"`). -""" -type Integration { - "The integration type identifier, unique within your organization." - id: ID! - - "The type of this integration (same as `id`)." - integrationTypeId: String! - - "Integration-specific configuration values. Structure varies by integration type." - config: Map! - - "Current lifecycle status of this integration." - status: IntegrationStatus! - - "When this integration was first created (UTC)." - createdAt: DateTime! - - "When this integration was last modified (UTC)." - updatedAt: DateTime! - - "When this integration is next scheduled to execute (UTC). Only present for integrations that run on a periodic schedule. `null` if the integration is disabled or does not have scheduled runs." - nextRunAt: DateTime -} - -""" -A newly activated integration with one-time setup instructions. - -Returned by `createIntegration` and `enableIntegration`. The `instructions` field -contains setup steps that may include sensitive credentials (e.g., IAM role trust -policies, webhook URLs). These instructions are only available at activation time -and should be stored securely. -""" -type IntegrationActivation { - "The integration type identifier, unique within your organization." - id: ID! - - "The type of this integration (same as `id`)." - integrationTypeId: String! - - "Integration-specific configuration values." - config: Map! - - "Current lifecycle status (typically `enabling` or `enabled`)." - status: IntegrationStatus! - - "When this integration was first created (UTC)." - createdAt: DateTime! - - "When this integration was last modified (UTC)." - updatedAt: DateTime! - - "When this integration is next scheduled to execute (UTC), if applicable." - nextRunAt: DateTime - - "One-time setup instructions for completing the integration. May contain sensitive credentials such as IAM trust policies or webhook secrets — store these securely." - instructions: String! -} - -type IntegrationsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type integration." - items: [Integration] -} - -type IntegrationPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: Integration -} - -type IntegrationActivationPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: IntegrationActivation -} - -""" -The deployment mode of this Massdriver server. - -Determines whether you are connecting to a self-managed installation or to -Massdriver's managed cloud service. This affects available features, authentication -options, and upgrade procedures. -""" -enum ServerMode { - "A self-hosted Massdriver installation running in your own infrastructure." - SELF_HOSTED - - "Massdriver Cloud, the fully managed SaaS service operated by Massdriver." - MANAGED -} - -"The type of email-based (non-SSO) authentication available on this server." -enum EmailAuthMethodType { - "Passwordless authentication using passkeys (WebAuthn\/FIDO2). The browser prompts for a biometric or security key." - PASSKEY -} - -""" -An SSO (Single Sign-On) provider configured for this Massdriver server. - -Each provider represents an OAuth 2.0 or OpenID Connect identity provider that -users can authenticate with. Use the `loginUrl` to redirect users to the -provider's login page. -""" -type SsoProvider { - "The provider's identifier (e.g., `\"google\"`, `\"okta\"`, `\"azure-ad\"`)." - name: String! - - "The URL to redirect the user to in order to start the SSO login flow." - loginUrl: String! - - "URL of the provider's icon, for rendering on login buttons." - uiIconUrl: String - - "Display label for the provider (e.g., `\"Sign in with Google\"`)." - uiLabel: String -} - -""" -An email-based authentication method available on this server. - -Email auth methods do not require an external identity provider. They are -configured at the server level and available to all users. -""" -type EmailAuthMethod { - "The authentication mechanism type (e.g., `PASSKEY`)." - name: EmailAuthMethodType! -} - -""" -Server-level feature flags that gate UI affordances and API behavior on this Massdriver instance. - -Feature flags here describe what an unauthenticated client can or cannot do against this server, -so login screens and signup flows can render the correct options. -""" -type ServerFeatures { - "Whether end users may create new organizations on this server. Self-hosted installations may be capped to a single organization by license." - orgCreationEnabled: Boolean! -} - -""" -Information about the Massdriver server you are connected to. - -Use this to discover the server's version, deployment mode, and available -authentication methods. This is typically the first query a client makes -to determine how to render the login screen and check API compatibility. -""" -type Server { - "The base URL of the Massdriver web application (e.g., `\"https:\/\/app.massdriver.cloud\"`)." - appUrl: String! - - "The server's semantic version (e.g., `\"1.2.3\"`)." - version: String! - - "Whether this is a self-hosted installation or Massdriver Cloud." - mode: ServerMode! - - "SSO identity providers available for authentication. Empty if no SSO is configured." - ssoProviders: [SsoProvider] - - "Email-based authentication methods available (e.g., passkeys). Empty if only SSO is available." - emailAuthMethods: [EmailAuthMethod] - - "Server-level feature flags. Use these to drive UI affordances on the login and signup flows." - features: ServerFeatures! -} - -""" -The resource level where a custom attribute applies. - -Attributes set at hierarchy scopes (`PROJECT`, `ENVIRONMENT`, `COMPONENT`) cascade -downward — an attribute set at `PROJECT` is inherited by all environments, instances, -deployments, and resources in that project. `REPO`-scoped attributes apply to OCI -repositories; the bundle name is propagated to components, instances, deployments, -and resources via the `md-repo` system attribute. -""" -enum AttributeScope { - "Attribute is set on individual projects." - PROJECT - - "Attribute is set on individual environments." - ENVIRONMENT - - "Attribute is set on blueprint components (design-time)." - COMPONENT - - "Attribute is set on individual OCI repositories (bundles)." - REPO -} - -"Fields available for sorting the custom attributes list." -enum CustomAttributesSortField { - "Sort alphabetically by attribute key name (A-Z or Z-A)." - KEY - - "Sort by resource scope level." - SCOPE - - "Sort by creation date (oldest first or newest first)." - CREATED_AT -} - -"Sorting options for the custom attributes list. Specify a field and direction." -input CustomAttributesSort { - "The field to sort by." - field: CustomAttributesSortField! - - "Sort direction (`ASC` or `DESC`)." - order: SortOrder! -} - -"Declare a custom attribute key for your organization. Custom attributes control which user-defined attribute keys can be set on resources at each level of the hierarchy. System attributes (md-*) are auto-injected by Massdriver and do not need to be declared." -input CreateCustomAttributeInput { - "1-64 characters, identifier-like: starts with a letter or underscore; letters, digits, and underscores only. Case-insensitive (TEAM and team are the same key). Keys starting with md- are reserved for system use." - key: String! - - "Whether this attribute must be set when creating a resource at the specified scope." - required: Boolean - - "The resource level where this attribute is set. Hierarchy scopes (PROJECT, ENVIRONMENT, COMPONENT) cascade values downward to instances, deployments, and resources. REPO scope applies to OCI repositories; the bundle name propagates to derived components, instances, deployments, and resources via the md-repo system attribute." - scope: AttributeScope! - - "The closed set of values this attribute may take. Must contain at least one entry, no duplicates, and may not contain the literal \"*\" (reserved for a future \"any value\" semantic)." - values: [String!]! -} - -"Replace the closed set of values for an existing custom attribute. The key and scope are immutable." -input UpdateCustomAttributeInput { - "Whether this attribute must be set when creating a resource at the specified scope." - required: Boolean - - "The closed set of values this attribute may take. Must contain at least one entry, no duplicates, and may not contain the literal \"*\" (reserved for a future \"any value\" semantic). Replaces the current set in full." - values: [String!]! -} - -""" -A user-declared attribute key, the resource scope where it applies, and whether it is required. - -Custom attributes enforce consistent metadata across your organization. When a custom -attribute is marked as **required**, any resource created at the specified scope must -include the attribute key. Optional custom attributes define allowed keys without -mandating them. - -System attributes (`md-*`) are auto-injected by Massdriver and are not declared here — -only user-defined keys live in this list. - -Use the `customAttributeSchema` query to generate a JSON Schema document narrowed -to the values your policies permit for a given action — useful for client-side -validation that mirrors what the API will accept on write. Use -`customAttributeValues` to fetch just the closed set for a single key. - -**Example:** A custom attribute with `key: "TEAM"`, `scope: PROJECT`, `required: true` -means every project must have a `TEAM` attribute set at creation time. -""" -type CustomAttribute { - "Unique identifier for this custom attribute." - id: ID! - - "The attribute key name (e.g., `TEAM`, `COST_CENTER`, `DOMAIN`). Case-sensitive." - key: String! - - "The resource level where this attribute must or may be set." - scope: AttributeScope! - - "When `true`, resources created at the specified scope must include this attribute. When `false`, the attribute is allowed but optional." - required: Boolean! - - "The closed set of values this attribute may take. Resource attribute writes and policy conditions referencing this key must use one of these values." - values: [String!]! - - "When this custom attribute was created (UTC)." - createdAt: DateTime! - - "When this custom attribute was last modified (UTC)." - updatedAt: DateTime! -} - -type CustomAttributesPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type custom_attribute." - items: [CustomAttribute] -} - -type CustomAttributePayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: CustomAttribute -} - -""" -The access level assigned to a group within an organization. - -Every group has exactly one role that determines what its members can do: - -- **`ORGANIZATION_ADMIN`** — Full administrative access across all projects. -- **`ORGANIZATION_VIEWER`** — Read-only access across all projects. -- **`CUSTOM`** — Fine-grained, project-level access grants. Use this when you need - to give a team access to specific projects without organization-wide permissions. -""" -enum GroupRole { - "Full administrative access to all projects and settings in the organization. Members can manage groups, service accounts, billing, and all infrastructure." - ORGANIZATION_ADMIN - - "Read-only access to all projects in the organization. Members can view infrastructure, deployments, and logs but cannot make changes." - ORGANIZATION_VIEWER - - "Project-level access grants. Members only see projects explicitly assigned to this group, with either `project_admin` or `project_viewer` permissions per project." - CUSTOM -} - -"Available fields for sorting groups." -enum GroupsSortField { - "Sort alphabetically by group name (A-Z or Z-A)." - NAME - - "Sort by creation date (oldest first or newest first)." - CREATED_AT -} - -"Available fields for sorting a group's members." -enum GroupMembersSortField { - "Sort alphabetically by email (A-Z or Z-A)." - EMAIL -} - -"Available fields for sorting a group's pending invitations." -enum GroupInvitationsSortField { - "Sort alphabetically by email (A-Z or Z-A)." - EMAIL - - "Sort by when the invitation was sent." - CREATED_AT -} - -"Sorting options for the groups list." -input GroupsSort { - "The field to sort by." - field: GroupsSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -"Sorting options for a group's members list." -input GroupMembersSort { - "The field to sort by." - field: GroupMembersSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -"Sorting options for a group's pending invitations." -input GroupInvitationsSort { - "The field to sort by." - field: GroupInvitationsSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -"Create a new group. Groups control which projects members can access." -input CreateGroupInput { - "What this group is for" - description: String - - "A human-readable name for the group" - name: String! -} - -"Update a group's name or description." -input UpdateGroupInput { - "What this group is for" - description: String - - "A human-readable name for the group" - name: String! -} - -"Add an account to a group by email. If the email belongs to an existing organization member they are added directly; otherwise an invitation is sent." -input AddAccountToGroupInput { - "Email address of the user to add to the group." - email: String! -} - -""" -A human user account in your organization. - -Returned wherever the API exposes the people behind group memberships and -organization roster — for example as an element of `Group.members` and -`Organization.members`. Every account in the result set is already a -member of at least one group in the organization. -""" -type Account { - "Unique identifier for this account." - id: ID! - - "Email address used to sign in and receive notifications." - email: String! - - "Given name as it appears in the UI, or `null` if not set." - firstName: String - - "Family name as it appears in the UI, or `null` if not set." - lastName: String -} - -""" -A collection of users and service accounts that share the same access level within your organization. - -Groups are the primary mechanism for managing access control in Massdriver. Rather than -assigning permissions to individual users, you add them to groups that define what they -can see and do. - -```mermaid -graph TD - O["Organization"] --> G1["Group: Admins"] - O --> G2["Group: Developers"] - O --> G3["Group: Custom"] - G1 --> U1["User: alice@co.com"] - G2 --> U2["User: bob@co.com"] - G2 --> SA1["Service Account: ci-bot"] - G3 -->|"project_admin"| P1["Project: backend"] - G3 -->|"project_viewer"| P2["Project: frontend"] -``` - -**Built-in groups** — Every organization starts with an `Admins` group (`organization_admin` role) -and a `Viewers` group (`organization_viewer` role). These cannot be deleted. - -**Custom groups** — Create custom groups with the `CUSTOM` role to grant project-level access. -Each custom group can be assigned `project_admin` or `project_viewer` on specific projects. - -**Members** — Both human users and service accounts can be group members. Users live under -`members` and are added via `addAccountToGroup` (auto-adds existing org members or sends an -invitation otherwise). Service accounts live under `serviceAccounts` and are added via -`addServiceAccountToGroup`. -""" -type Group { - "Unique identifier for this group." - id: ID! - - "Human-readable name displayed in the UI and API responses." - name: String! - - "Optional text explaining the purpose of this group." - description: String - - "The access level this group grants to its members." - role: GroupRole! - - "When this group was created (UTC)." - createdAt: DateTime! - - "When this group was last modified (UTC)." - updatedAt: DateTime! - - """ - Paginated list of ABAC policies attached to this group as the principal. - - Group policies define what every member of the group can do across the organization. - """ - policies( - "Narrow the list by `effect` or `action`. Filters combine with AND." - filter: PoliciesFilter - - "How to sort results. Defaults to oldest first." - sort: PoliciesSort - - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): PoliciesPage - - """ - Paginated list of human accounts that are members of this group. - - Service accounts are exposed separately via `serviceAccounts` — pair both queries when - rendering the full membership of a group. - """ - members( - "How to sort results. Defaults to email ascending." - sort: GroupMembersSort - - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): AccountsPage - - """ - Paginated list of pending invitations to this group. Visible to organization admins only. - - Pending invitations are users who have been invited by email but have not yet accepted. - Once accepted, the row is replaced by a `GroupMembership` and no longer appears here. - Non-admin callers receive `null` here with a top-level forbidden error so the rest of the - response still resolves; viewers should query `Viewer.invites` for their own invitations. - """ - invitations( - "How to sort results. Defaults to email ascending." - sort: GroupInvitationsSort - - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): GroupInvitationsPage - - """ - Paginated list of service accounts in this group. - - Human accounts are exposed separately via `members` — pair both queries when rendering - the full group membership. - """ - serviceAccounts( - "How to sort results. Defaults to name ascending." - sort: ServiceAccountsSort - - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): ServiceAccountsPage -} - -""" -A pending invitation for a user to join a group. - -Invitations are sent by email. The invited user must accept the invitation to become -a group member. Pending invitations can be revoked by an organization admin before -they are accepted. -""" -type GroupInvitation { - "Unique identifier for this invitation." - id: ID! - - "Email address the invitation was sent to." - email: String! - - "When the invitation was sent (UTC)." - createdAt: DateTime! -} - -"Confirmation payload returned after removing a member from a group." -type DeletedGroupMember { - "Email address of the member who was removed." - email: String! -} - -""" -The result of `addAccountToGroup`. - -- When the email already belongs to an organization member, the API adds them to the group - directly and returns an `Account`. -- When the email is new to the organization, the API sends an invitation and returns a - `GroupInvitation`. The recipient becomes a member when they accept. - -Use inline fragments to react to each branch: - -```graphql -mutation { - addAccountToGroup( - organizationId: "my-org" - groupId: "platform" - input: { email: "newuser@example.com" } - ) { - successful - result { - ... on Account { id email } - ... on GroupInvitation { email createdAt } - } - } -} -``` -""" -union AddedAccountToGroup = Account | GroupInvitation - -type GroupsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type group." - items: [Group] -} - -type AccountsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type account." - items: [Account] -} - -type GroupInvitationsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type group_invitation." - items: [GroupInvitation] -} - -type GroupPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: Group -} - -type GroupInvitationPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: GroupInvitation -} - -type DeletedGroupMemberPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: DeletedGroupMember -} - -type AddedAccountToGroupPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: AddedAccountToGroup -} - -"Create a new organization. An organization is the top-level container for all your projects, environments, and infrastructure resources." -input CreateOrganizationInput { - "A short, memorable identifier for looking up this organization in the API and CLI. Choose something concise and meaningful—human-readable, not a UUID. Max 20 characters, lowercase alphanumeric only (a-z, 0-9). Immutable after creation." - id: String! - - "A human-readable name for the organization." - name: String! -} - -"Update mutable settings on an organization. The identifier is fixed at creation and cannot be changed." -input UpdateOrganizationInput { - "Display name shown in the UI and CLI. 1–255 characters." - name: String! -} - -""" -Subscription status for an organization. - -Use this to display billing warnings, gate features for unpaid accounts, -or guide users toward resolving payment issues. Visible to every member -of the organization regardless of role. -""" -enum OrganizationSubscriptionStatus { - "Free trial period. The organization has full access until the trial expires." - TRIAL - - "Subscription is active and in good standing." - ACTIVE - - "A payment is overdue. Access continues but action is needed to avoid suspension." - PAST_DUE - - "The free trial has ended without a subscription. Functionality is limited." - EXPIRED - - "The subscription was canceled. The organization is in a grace period." - CANCELED - - "The account is suspended due to billing issues. Most operations are blocked." - SUSPENDED - - "A payment is being processed. This is typically a brief transitional state." - PAYMENT_PENDING - - "The most recent payment attempt failed. Update payment details to restore access." - PAYMENT_FAILED -} - -"Available fields for sorting organizations." -enum OrganizationsSortField { - "Sort alphabetically by organization name (A-Z or Z-A)." - NAME - - "Sort by when the organization was created (oldest or newest first)." - CREATED_AT -} - -"Sorting options for organization lists." -input OrganizationsSort { - "The field to sort by." - field: OrganizationsSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -""" -The top-level account that owns all your infrastructure, projects, and team members. - -An organization is the root of the Massdriver resource hierarchy. Everything you build -and deploy lives under an organization: **Projects** contain your infrastructure designs, -**Environments** (like staging and production) are where those designs come to life, and -**Instances** are the actual running cloud resources. - -```mermaid -graph TD - O["Organization"] --> P1["Project"] - O --> P2["Project"] - P1 --> E1["Environment: staging"] - P1 --> E2["Environment: production"] - E1 --> I1["Instance"] - E1 --> I2["Instance"] -``` - -Members access resources through **group memberships** with role-based permissions. -Custom attributes defined at the organization level govern attribute metadata across all child resources. - -Administrative fields (`billing`, `members`, `customAttributes`) resolve to `null` for -callers who lack the corresponding ABAC action; in that case a top-level `FORBIDDEN` -error is added to the response while the rest of the organization still resolves. -""" -type Organization { - id: ID! - - "Display name shown in the UI and CLI." - name: String! - - "When this organization was created (UTC)." - createdAt: DateTime! - - "When this organization was last modified (UTC)." - updatedAt: DateTime! - - """ - Subscription status of the organization. Visible to every member so the UI can - surface billing warnings or lock down features for delinquent accounts. Detailed - payment, seat, and invoice data live behind the admin-only `billing` field. - """ - subscriptionStatus: OrganizationSubscriptionStatus! - - """ - When the current free trial expires (UTC). - - Only populated while `subscriptionStatus` is `TRIAL`. If a paid plan is not - active by this timestamp, `subscriptionStatus` becomes `EXPIRED` and most - write operations are blocked until billing is resolved. Once the organization - upgrades to a paid plan or the trial expires, this field returns `null`. - """ - trialEndsAt: DateTime - - """ - Date the current paid plan period ends. - - Populated for annual plans (the date the prepaid year runs out) and for grace - periods after a failed payment. `null` for organizations on monthly plans - (which auto-renew without a fixed end date), trial-only organizations, and - organizations that have never had a paid plan. - """ - planExpiresOn: Date - - "The organization's logo image, or `null` if no logo has been uploaded." - logo: LogoOrganization - - """ - Paginated list of human accounts that are members of at least one group in this organization. - - Sorted by email ascending. Service accounts live under the top-level `serviceAccounts` - query — pair both when rendering the full organization roster. - - Requires the `organization:manage` action. Callers without it receive `null` here - along with a top-level `FORBIDDEN` error so the rest of the response still resolves. - """ - members( - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): AccountsPage - - """ - Paginated list of custom attributes that govern attribute metadata across this organization. - - Requires the `organization:manage` action. Callers without it receive - `null` along with a top-level `FORBIDDEN` error. - """ - customAttributes( - "How to sort results. Defaults to alphabetical by key." - sort: CustomAttributesSort - - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): CustomAttributesPage - - """ - Subscription, trial, and upgrade information for this organization. - - Requires the `organization:manage` action. Callers without it receive - `null` along with a top-level `FORBIDDEN` error. The non-sensitive - `subscriptionStatus` field at the top of the type stays visible to every member. - """ - billing: OrganizationBilling -} - -"Confirmation payload returned after removing a member from the organization." -type DeletedOrganizationMember { - "Email address of the member who was removed." - email: String! -} - -type OrganizationsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type organization." - items: [Organization] -} - -type OrganizationPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: Organization -} - -type DeletedOrganizationMemberPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: DeletedOrganizationMember -} - -""" -An organization's logo image with metadata. - -Upload a logo via the `setOrganizationLogo` mutation. Supported formats are -PNG, JPG, GIF, and WebP with a maximum file size of 1 MB. -""" -type LogoOrganization { - "Unique identifier for this logo." - id: ID! - - "Fully-qualified URL to the logo image. This URL is stable and safe to cache." - url: String! - - "MIME type of the image (e.g., `image\/png`, `image\/webp`)." - contentType: String! - - "File size in bytes. Maximum allowed is 1,048,576 (1 MB)." - fileSize: Int! - - "Image width in pixels, or `null` if dimensions could not be determined." - width: Int - - "Image height in pixels, or `null` if dimensions could not be determined." - height: Int - - "When the logo was first uploaded (UTC)." - createdAt: DateTime! - - "When the logo was last replaced (UTC)." - updatedAt: DateTime! -} - -type LogoOrganizationPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: LogoOrganization -} - -"Cadence at which an organization or plan is billed." -enum BillingFrequency { - "Billed every month." - MONTHLY - - "Billed once per year." - ANNUALLY -} - -""" -An upgrade or switch path the organization can subscribe to. - -Each plan represents a Stripe payment link prefilled with this organization's -context, so following `url` takes the buyer straight to checkout. -""" -type BillingPlan { - "Plan display name (e.g., `\"Startup Plan\"`, `\"Growth Plan\"`)." - name: String! - - "Short marketing blurb explaining what the plan includes. Safe to render in the UI." - description: String! - - "External Stripe checkout URL. Already prefilled with the organization's reference and email — open it directly." - url: String! - - "Number of seats included in the plan." - seats: Int! - - "Plan price for the full billing period, expressed in the smallest subunit of `currency` (e.g., cents for `USD`)." - price: Int! - - "ISO 4217 currency code for `price` (e.g., `USD`)." - currency: String! - - "How often this plan bills." - frequency: BillingFrequency! -} - -""" -Subscription, trial, and upgrade information for an organization. - -Returned by `Organization.billing` for organization administrators only. -Non-admin members will see `null` here even when they can otherwise view the organization. -""" -type OrganizationBilling { - "Number of seats currently provisioned on the subscription (the org's max member count)." - seats: Int! - - "Current subscription cost for the full billing period, expressed in the smallest subunit of `currency` (e.g., cents for `USD`). `null` if the org is not on a paid plan." - cost: Int - - "ISO 4217 currency code for `cost` (e.g., `USD`). `null` if the org is not on a paid plan." - currency: String - - "Cadence the org is currently billed at. `null` if the org is not on a paid plan." - frequency: BillingFrequency - - "Friendly attribution name for the partner that referred this org (e.g., `\"Y Combinator\"`)." - affiliate: String - - "When the current free trial expires (UTC). `null` once `subscriptionStatus` is anything other than `TRIAL`." - trialEndsAt: DateTime - - "End date of the current paid plan or grace period. `null` when not applicable (e.g., monthly plans)." - planExpiresOn: Date - - "Stripe Customer Portal URL for managing payment methods, invoices, and cancellation." - portalUrl: String! - - "Plans the organization can upgrade or switch to. URLs are prefilled with this org's reference for one-click checkout." - plans: [BillingPlan!]! -} - -"Available fields for sorting linked identities." -enum IdentitiesSortField { - "Sort alphabetically by provider name (e.g., github, google)." - PROVIDER - - "Sort by when the identity was linked (oldest or newest first)." - CREATED_AT -} - -"Available fields for sorting pending invitations." -enum InvitesSortField { - "Sort by when the invitation was sent (oldest or newest first)." - CREATED_AT -} - -"The type of group, based on its role within the organization." -enum ViewerGroupType { - "Organization administrators with full access to all projects and settings." - ORGANIZATION_ADMIN - - "Organization viewers with read-only access to all projects." - ORGANIZATION_VIEWER - - "Custom group with project-level permission grants." - CUSTOM -} - -"Sorting options for the viewer's linked identities list." -input IdentitiesSort { - "The field to sort by." - field: IdentitiesSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -"Sorting options for the viewer's pending invitations list." -input InvitesSort { - "The field to sort by." - field: InvitesSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -""" -An OAuth or OIDC provider identity linked to your account. - -Identities represent external authentication providers (e.g., Google, GitHub) that you have -connected to your Massdriver account. You can use any linked identity to sign in. -""" -type AccountIdentityViewer { - "Unique identifier for this linked identity." - id: ID! - - "The OAuth\/OIDC provider name (e.g., `google`, `github`)." - provider: String! - - "When this identity was linked to your account (UTC)." - createdAt: DateTime! -} - -type IdentitiesViewerPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type account_identity_viewer." - items: [AccountIdentityViewer] -} - -"Organization details included in a group invitation." -type InviteOrganizationViewer { - id: ID! - - "Name of the organization that sent the invitation." - name: String! -} - -""" -Group details included in a group invitation. - -Shows which group you are being invited to join, its role type, and which organization -it belongs to, so you can make an informed decision before accepting. -""" -type InviteGroupViewer { - "Unique identifier for the group." - id: ID! - - "Name of the group you are being invited to." - name: String! - - "The role type of this group (admin, viewer, or custom)." - type: ViewerGroupType! - - "The organization this group belongs to." - organization: InviteOrganizationViewer! -} - -""" -A pending invitation to join a group. - -When someone invites you to a group, the invitation appears here. Use the -`acceptGroupInvite` mutation to accept it and become a member of the group, -gaining whatever access the group provides. -""" -type InviteViewer { - "Unique identifier for this invitation." - id: ID! - - "The email address this invitation was sent to." - email: String! - - "When the invitation was sent (UTC)." - createdAt: DateTime! - - "The group you have been invited to join, including its organization." - group: InviteGroupViewer! -} - -type InvitesViewerPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type invite_viewer." - items: [InviteViewer] -} - -""" -A profile avatar image. - -Avatars are uploaded via the `setAccountAvatar` mutation and served at a stable URL. -Supported formats: PNG, JPG, GIF, and WebP (max 1 MB). -""" -type AvatarViewer { - "Unique identifier for this avatar." - id: ID! - - "Publicly accessible URL where the avatar image is served." - url: String! - - "MIME type of the image (e.g., `image\/png`, `image\/jpeg`)." - contentType: String! - - "File size in bytes." - fileSize: Int! - - "Image width in pixels, if available." - width: Int - - "Image height in pixels, if available." - height: Int - - "When the avatar was first uploaded (UTC)." - createdAt: DateTime! - - "When the avatar was last replaced (UTC)." - updatedAt: DateTime! -} - -type InviteViewerPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: InviteViewer -} - -type AvatarViewerPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: AvatarViewer -} - -""" -The authenticated human user (account). - -Returned by the `viewer` query when the request is authenticated with a user session or -on behalf of a human account. Contains profile information, organization memberships, -linked OAuth identities, and pending group invitations. - -Use `organizations` to build an org switcher, `defaultOrganization` to set an initial -context, and `invites` to show pending invitations the user can accept. -""" -type AccountViewer { - "Unique identifier for this account." - id: ID! - - "Primary email address associated with the account." - email: String! - - "First name, if set in the user's profile." - firstName: String - - "Last name, if set in the user's profile." - lastName: String - - "When this account was created (UTC)." - createdAt: DateTime! - - "When this account's profile was last modified (UTC)." - updatedAt: DateTime! - - "The user's profile avatar image, or `null` if none has been uploaded." - avatar: AvatarViewer - - """ - Organizations you are a member of. - - Returns a paginated list of all organizations where you have at least one group - membership. Defaults to alphabetical order by name. - """ - organizations( - "Sort field and direction. Defaults to name ascending." - sort: OrganizationsSort - - "Pagination cursor from a previous response to fetch the next page." - cursor: Cursor - ): OrganizationsPage - - """ - Your most recently joined organization. - - Useful for setting a default context when the user first opens the application. - Returns `null` if the user does not belong to any organization. - """ - defaultOrganization: Organization - - """ - OAuth and OIDC provider identities linked to your account. - - Lists all external authentication providers (e.g., Google, GitHub) that you have connected. - You can sign in with any linked identity. - """ - identities( - "Sort field and direction. Defaults to provider name ascending." - sort: IdentitiesSort - - "Pagination cursor from a previous response to fetch the next page." - cursor: Cursor - ): IdentitiesViewerPage - - """ - Pending group membership invitations. - - Lists invitations from organizations you have not yet accepted. Use the - `acceptGroupInvite` mutation to accept an invitation and join the group. - Defaults to newest first. - """ - invites( - "Sort field and direction. Defaults to newest first." - sort: InvitesSort - - "Pagination cursor from a previous response to fetch the next page." - cursor: Cursor - ): InvitesViewerPage -} - -""" -The authenticated service account (API client). - -Returned by the `viewer` query when the request is authenticated with a service account -credential (Basic auth or access token). Contains the service account's identity and the -organization it belongs to. -""" -type ServiceAccountViewer { - "Unique identifier for this service account." - id: ID! - - "Human-readable name of the service account." - name: String! - - "Optional text describing the purpose of this service account." - description: String - - "When this service account was created (UTC)." - createdAt: DateTime! - - "When this service account was last modified (UTC)." - updatedAt: DateTime! - - "The organization this service account belongs to." - organization: Organization! -} - -""" -The authenticated entity making this API request. - -This is a union type — check `__typename` to determine whether the viewer is a human -user (`AccountViewer`) or an API client (`ServiceAccountViewer`), then query the -appropriate fields. - -**Example:** - -```graphql -query { - viewer { - __typename - ... on AccountViewer { - id - email - firstName - organizations { data { id name } } - } - ... on ServiceAccountViewer { - id - name - organization { id name } - } - } -} -``` -""" -union Viewer = AccountViewer | ServiceAccountViewer - -"Available fields for sorting links." -enum LinksSortField { - "Chronological by creation time (oldest or newest first)." - CREATED_AT -} - -"Sorting options for the links list." -input LinksSort { - "The field to sort by." - field: LinksSortField! - - "`ASC` for A-Z \/ oldest first, `DESC` for Z-A \/ newest first." - order: SortOrder! -} - -"Filter which links to return." -input LinksFilter { - "Match by the source (from) component's ID (e.g., `myproj-database`)." - fromComponentId: IdFilter - - "Match by the destination (to) component's ID (e.g., `myproj-app`)." - toComponentId: IdFilter -} - -"Create a link between two components in a project's blueprint. Links connect an output field on the source component to an input field on the destination component, establishing data flow between infrastructure resources." -input LinkComponentsInput { - "ID of the component that produces the resource (e.g., 'myproj-database')." - fromComponentId: ID! - - "Output field name on the source component" - fromField: String! - - "Version constraint for the source component (e.g., '~1.0', '1.2.3', 'latest')" - fromVersion: VersionConstraint! - - "ID of the component that consumes the resource (e.g., 'myproj-app')." - toComponentId: ID! - - "Input field name on the destination component" - toField: String! - - "Version constraint for the destination component (e.g., '~1.0', '1.2.3', 'latest')" - toVersion: VersionConstraint! -} - -""" -A design-time dependency between two components in a blueprint. - -A link declares that one component's output should be wired into another -component's input. For example, a link from a database component's -`authentication` output to an application component's `database` input -ensures the app receives the database connection string. - -At deploy time, each link is realized as a **connection** in the environment, -wiring the actual instance outputs to instance inputs. -""" -type Link { - "Unique identifier for this link." - id: ID! - - "The output field name on the source component (e.g., `authentication`)." - fromField: String! - - "The input field name on the destination component (e.g., `database`)." - toField: String! - - "When this link was created (UTC)." - createdAt: DateTime! - - "When this link was last modified (UTC)." - updatedAt: DateTime! - - "The source component that produces the output." - fromComponent: Component - - "The destination component that consumes the input." - toComponent: Component -} - -""" -A project's infrastructure blueprint -- the design-time architecture. - -The blueprint is the canonical description of how your infrastructure fits -together. It contains **components** (the bundles you want to deploy) and -**links** (the wiring between them). - -Every project has exactly one blueprint. When you deploy to an environment, -the blueprint is realized as an **environment blueprint** containing live -**instances** and **connections**. - -```mermaid -graph TB - subgraph "Design Time (Blueprint)" - C1["Component: database"] ---|"Link"| C2["Component: cache"] - end - subgraph "Runtime (Environment Blueprint)" - I1["Instance: database"] ---|"Connection"| I2["Instance: cache"] - end - C1 -.->|"deployed to"| I1 - C2 -.->|"deployed to"| I2 -``` -""" -type Blueprint { - """ - Paginated list of components in this blueprint. - - Returns all bundle slots that make up the project's architecture. - Defaults to alphabetical order by name. - """ - components( - "Narrow results by component ID or bundle name." - filter: ComponentsFilter - - "Sort field and direction. Defaults to `name` ascending." - sort: ComponentsSort - - "Pagination cursor returned by a previous page." - cursor: Cursor - ): ComponentsPage - - """ - Paginated list of links between components in this blueprint. - - Each link declares a dependency from one component's output to another's input. - Defaults to chronological order by creation time. - """ - links( - "Narrow results by source or destination component." - filter: LinksFilter - - "Sort field and direction. Defaults to `created_at` ascending." - sort: LinksSort - - "Pagination cursor returned by a previous page." - cursor: Cursor - ): LinksPage -} - -type LinksPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type link." - items: [Link] -} - -type LinkPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: Link -} - -"Available fields for sorting components." -enum ComponentsSortField { - "Alphabetical by component name (A-Z or Z-A)." - NAME - - "Chronological by creation time (oldest or newest first)." - CREATED_AT -} - -"Sorting options for the components list." -input ComponentsSort { - "The field to sort by." - field: ComponentsSortField! - - "`ASC` for A-Z \/ oldest first, `DESC` for Z-A \/ newest first." - order: SortOrder! -} - -"Filter which components to return." -input ComponentsFilter { - "Match by component ID (e.g., `myproj-database`). Supports `eq` and `in`." - id: IdFilter - - "Match by the OCI repository name of the underlying bundle." - ociRepoName: OciRepoNameFilter -} - -"Add an infrastructure component to a project's blueprint. Each component is a specific instance of a bundle (like a Redis cache or PostgreSQL database) that composes with other components to form your application." -input AddComponentInput { - "Key-value attributes for this component. Keys and values must be strings. Must conform to the organization's custom attributes for the component scope." - attributes: Map - - "Optional description of this component's purpose" - description: String - - "A short, memorable identifier for this component. This becomes the final segment of package identifiers. For example, project 'ecomm' with environment 'prod' and component 'db' creates 'ecomm-prod-db'. Max 20 characters, lowercase alphanumeric only (a-z, 0-9). Immutable after creation." - id: String! - - "Display name for this component (e.g., 'Billing Database')" - name: String! -} - -"Update an existing component's name, description, and attributes. The component ID and underlying bundle cannot be changed." -input UpdateComponentInput { - "Key-value attributes for this component. Keys and values must be strings. Must conform to the organization's custom attributes for the component scope." - attributes: Map - - "Optional description of this component's purpose" - description: String - - "Display name for this component (e.g., 'Billing Database')" - name: String -} - -"Set the position of a component on the canvas." -input SetComponentPositionInput { - "Horizontal position in pixels" - x: Int! - - "Vertical position in pixels" - y: Int! -} - -"A component's position on the visual canvas, in pixel coordinates." -type ComponentPosition { - "Horizontal offset in pixels from the canvas origin." - x: Int! - - "Vertical offset in pixels from the canvas origin." - y: Int! -} - -""" -A bundle placed in a project's blueprint, representing a slot for deployable infrastructure. - -A component is the **design-time** building block of your architecture. It says -"I want a database here" or "I need a Kubernetes cluster there." The component -defines *what* to deploy; the actual running infrastructure lives in **instances** --- one per environment the component is deployed to. - -Components are connected to each other via **links**, which declare that one -component's output (e.g., a connection string) should be wired into another -component's input. -""" -type Component { - id: ID! - - "Human-readable display name shown in the UI." - name: String! - - "Optional free-text description of this component's purpose." - description: String - - "Key-value attributes assigned directly to this component." - attributes: Map! - - """ - The full attribute map the authorization system evaluates policies against for - this component — user attributes merged with the parent project (project wins on - conflict) plus auto-injected `md-*` system attributes. - - System attributes always present on a component: - - `md-id` — the component's identifier - - `md-project` — the project's identifier - - `md-component` — the component's local identifier - """ - effectiveAttributes: Map! - - "Position on the visual canvas. Null if never placed." - position: ComponentPosition - - "When this component was created (UTC)." - createdAt: DateTime! - - "When this component was last modified (UTC)." - updatedAt: DateTime! - - "Whether this component can be safely deleted. Check `constraints` for blocking conditions." - deletable: Deletable! - - "The OCI repository (bundle) this component is based on." - ociRepo: OciRepo - - "The project that owns this component." - project: Project - - """ - Paginated list of instances deployed from this component. - - Returns one instance per environment the component has been deployed to. - Defaults to alphabetical order by name. - """ - instances( - "Sort field and direction. Defaults to `name` ascending." - sort: InstancesSort - - "Pagination cursor returned by a previous page." - cursor: Cursor - ): InstancesPage -} - -type ComponentsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type component." - items: [Component] -} - -type ComponentPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: Component -} - -"Deploy an instance with configuration parameters. Params are validated against the bundle's params schema, cached on the instance, and snapshotted into the deployment." -input CreateDeploymentInput { - "The operation to perform: PROVISION to apply changes, PLAN to preview them, or DECOMMISSION to tear down infrastructure" - action: DeploymentAction! - - "A short message describing the reason for this deployment" - message: String - - "Bundle configuration parameters. Validated against the bundle's params schema." - params: Map! -} - -"Propose a deployment for human review. The deployment is created in PROPOSED status and will not execute until it is approved via approveDeployment. Params are validated against the bundle's params schema and snapshotted into the proposal." -input ProposeDeploymentInput { - "The operation that will run once the proposal is approved. PLAN is not proposable — use createDeployment for dry-run previews." - action: ProposeDeploymentAction! - - "A short message describing the reason for this proposal" - message: String - - "Bundle configuration parameters that should be applied if the proposal is approved. Validated against the bundle's params schema." - params: Map! -} - -""" -The current state of a deployment operation. - -Deployments created with `createDeployment` enter the lifecycle at `PENDING`. -Deployments created with `proposeDeployment` enter at `PROPOSED` and require -approval before they can run: - -```mermaid -stateDiagram-v2 - [*] --> PENDING: "createDeployment" - [*] --> PROPOSED: "proposeDeployment" - PROPOSED --> APPROVED: "approveDeployment" - PROPOSED --> REJECTED: "rejectDeployment" - PENDING --> RUNNING: "Execution starts" - APPROVED --> RUNNING: "Execution starts" - RUNNING --> COMPLETED: "Success" - RUNNING --> FAILED: "Error" -``` - -Once a deployment reaches a terminal state (`COMPLETED`, `FAILED`, or -`REJECTED`), it cannot transition again. -""" -enum DeploymentStatus { - "A proposed deployment awaiting human approval. Proposed deployments are not scheduled and do not block the queue." - PROPOSED - - "The proposal was rejected and will never run. Terminal." - REJECTED - - "The proposal was approved and is waiting to execute. Approved deployments are drained from the queue alongside PENDING deployments." - APPROVED - - "The deployment is queued and waiting for execution to begin." - PENDING - - "Infrastructure changes are actively being applied." - RUNNING - - "All infrastructure changes were applied successfully." - COMPLETED - - "The deployment encountered an error. Check deployment logs for details." - FAILED -} - -""" -The type of infrastructure operation to perform. - -Each action maps to a distinct phase of the infrastructure lifecycle: -- **PROVISION** creates new infrastructure or updates existing infrastructure to match the desired configuration. -- **DECOMMISSION** tears down all infrastructure managed by the instance. -- **PLAN** generates a dry-run preview showing what changes _would_ be made, without actually applying them. -""" -enum DeploymentAction { - "Create new infrastructure or update existing infrastructure to match the instance's current configuration." - PROVISION - - "Destroy all infrastructure managed by this instance. This action cannot be undone." - DECOMMISSION - - "Generate a dry-run preview of what changes would be made, without applying them. Useful for reviewing infrastructure changes before committing." - PLAN -} - -""" -Actions that support the propose/approve/reject workflow. `PLAN` is excluded -because a plan is already a non-destructive preview — gating it behind -approval adds no value. -""" -enum ProposeDeploymentAction { - "Propose creating or updating infrastructure. On approval, the deployment runs with the snapshotted params." - PROVISION - - "Propose destroying all infrastructure managed by this instance. On approval, the teardown runs." - DECOMMISSION -} - -"Available fields for sorting the deployments list." -enum DeploymentsSortField { - "Sort by the timestamp of the most recent change to the deployment record (status transitions, log compaction). This is what surfaces \"recently active\" work first." - UPDATED_AT - - "Sort by creation timestamp (oldest first when ascending, newest first when descending)." - CREATED_AT - - "Sort alphabetically by status name. Deployments with the same status are sub-sorted by newest first." - STATUS -} - -""" -Controls the sort order of the deployments list. - -Defaults to most recently active first (`updated_at` descending) when not specified. -""" -input DeploymentsSort { - "The field to sort by." - field: DeploymentsSortField! - - "`ASC` for A-Z \/ oldest first, `DESC` for Z-A \/ newest first." - order: SortOrder! -} - -""" -Narrows the deployments list to only matching records. - -All filters are combined with AND logic. Omit a filter to skip that criterion. -""" -input DeploymentsFilter { - "Return only deployments for the specified instance(s)." - instanceId: IdFilter - - "Return only deployments in the specified status(es)." - status: DeploymentStatusFilter - - "Return only deployments with the specified action type(s)." - action: DeploymentActionFilter -} - -""" -A record of an infrastructure provisioning operation. - -Each deployment tracks a single action (`PROVISION`, `DECOMMISSION`, or `PLAN`) against -an instance. Deployments are immutable once created — you cannot modify a deployment, -only create new ones. - -Use the `status` field to monitor progress and `elapsed_time` to track duration. -The `deployed_by` field identifies the user or service account that initiated the operation. -""" -type Deployment { - "Unique identifier for this deployment." - id: ID! - - "Current lifecycle state of this deployment." - status: DeploymentStatus! - - "The infrastructure operation this deployment performs." - action: DeploymentAction! - - "The bundle version used for this deployment (e.g., `1.2.0`)." - version: String! - - "Snapshot of the instance configuration at the time this deployment was enqueued. Independent of the instance's current `params` — later edits do not mutate this record." - params: Map - - "An optional message describing the purpose of this deployment, similar to a commit message." - message: String - - "When this deployment was created (UTC)." - createdAt: DateTime! - - "When this deployment record was last updated (UTC)." - updatedAt: DateTime! - - "When the deployment last changed status (UTC). Null if the deployment is still `PENDING`." - lastTransitionedAt: DateTime - - """ - Wall-clock duration of the deployment in seconds. - - For `RUNNING` deployments, this is a live timer counting from creation. - For terminal states, this is the total time from creation to the final status transition. - Returns `0` for deployments that have not yet executed (`PENDING`, `PROPOSED`, `APPROVED`). - """ - elapsedTime: Int! - - "The name of the user or service account that initiated this deployment. Null if the initiator has been removed." - deployedBy: String - - "The instance that this deployment operates on." - instance: Instance - - """ - All log batches emitted for this deployment so far, ordered oldest-first. - - Each entry is a single batch (`logset`) as emitted by the provisioner — its - `message` may contain multiple `\n`-separated lines written in the same - flush. For live streaming, open a `deploymentEvents` subscription after - loading this field; the subscription fires on every subsequent append. - """ - logs: [DeploymentLog!]! -} - -""" -A single batch of deployment logs emitted by the provisioner. - -One `DeploymentLog` corresponds to one worker flush, which may contain -multiple log lines joined by `\n` in the `message` field. -""" -type DeploymentLog { - "When the provisioner flushed this batch of logs (UTC)." - timestamp: DateTime! - - "Raw log text. May span multiple lines separated by `\\n`." - message: String! -} - -type DeploymentsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type deployment." - items: [Deployment] -} - -type DeploymentPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: Deployment -} - -""" -Side-by-side comparison of two deployments. - -Returned by `compareDeployments`. Use this to audit what changed between two -points in an instance's history ("what did this deploy change?") or to -contrast two different deployments against each other. - -The comparison is limited to snapshotted configuration — bundle version and -params. Runtime state, logs, and produced artifacts are out of scope. -""" -type DeploymentComparison { - "The deployment on the source side of the comparison." - source: Deployment! - - "The deployment on the target side of the comparison." - target: Deployment! - - "Bundle version on each side, with an `equal` flag for quick check." - version: VersionComparison! - - "Flat, leaf-level diff of the two deployments' snapshotted params. Empty when both snapshots have no values to compare." - params: [ParamComparison!]! -} - -""" -Current state of a cloud metric alarm. - -A `null` `currentState` on an alarm indicates the alarm has been configured -but no state has been reported yet. -""" -enum AlarmStatus { - "The metric is within configured thresholds." - OK - - "The metric has crossed the configured threshold and the alarm is firing." - ALARM -} - -"Available fields for sorting the instance alarms list." -enum InstanceAlarmsSortField { - "Alphabetical by alarm display name (A-Z or Z-A)." - DISPLAY_NAME - - "Chronological by creation time (oldest or newest first)." - CREATED_AT -} - -""" -A key-value pair identifying the specific cloud resource a metric applies to. - -Examples: `{ name: "DBInstanceIdentifier", value: "db-abc123" }` for AWS RDS, -`{ name: "InstanceId", value: "i-0a1b2c3d" }` for AWS EC2. -""" -type AlarmMetricDimension { - "Dimension name as defined by the cloud provider." - name: String! - - "Dimension value identifying the monitored resource." - value: String! -} - -""" -The cloud metric an alarm is evaluating. - -Shape and populated fields vary by provider. AWS and Azure populate -`statistic` (e.g., `Average`, `Sum`, `Maximum`); GCP does not. `dimensions` -are populated when the provider exposes them as structured key-value pairs. -""" -type AlarmMetric { - "Cloud service namespace that categorizes the metric. Examples: `AWS\/RDS`, `Microsoft.Cache\/Redis`, `cloudsql_database`." - namespace: String - - "Metric name within the namespace. Examples: `CPUUtilization` (AWS), `allpercentprocessortime` (Azure)." - name: String - - "Aggregation function applied to metric samples. Examples: `Average`, `Sum`, `Maximum`. May be `null` for providers that don't use this concept (e.g., GCP)." - statistic: String - - "Cloud region this metric is scoped to, when provider-reported." - region: String - - "Dimensions identifying the specific cloud resource being monitored. Empty list when the provider doesn't report structured dimensions." - dimensions: [AlarmMetricDimension!]! -} - -""" -A single state transition reported for an alarm. - -Alarm states are append-only and recorded each time the alarm's status changes -(duplicate `OK -> OK` or `ALARM -> ALARM` updates are deduplicated at ingest). -The most recent state is exposed as `alarm.currentState`. -""" -type AlarmState { - "Unique identifier for this state record." - id: ID! - - "Whether the alarm is firing (`ALARM`) or clear (`OK`)." - status: AlarmStatus! - - "Provider-supplied human-readable message describing the state change." - message: String - - "When the state change occurred in the cloud provider (UTC)." - occurredAt: DateTime! -} - -""" -A cloud metric alarm attached to an instance. - -Receives state updates via webhooks from AWS CloudWatch, Azure Monitor, -GCP Cloud Monitoring, or Prometheus Alertmanager. - -Check `currentState` to see whether the alarm is firing. A `null` -`currentState` means no state has been reported yet for this alarm. -""" -type Alarm { - "Unique identifier for this alarm." - id: ID! - - "Human-readable name for the alarm, set by the cloud provider when the alarm was registered." - displayName: String! - - "The cloud provider's unique identifier for the alarm (e.g., CloudWatch AlarmArn, GCP alert policy name, Azure alert id)." - cloudResourceId: String! - - "How the metric is compared against `threshold` (e.g., `GREATER_THAN`, `LESS_THAN`, `GREATER_THAN_OR_EQUAL_TO`, `LESS_THAN_OR_EQUAL_TO`). May be null for Alertmanager and GCP alarms." - comparisonOperator: String - - "The value crossed to trigger the alarm, compared using `comparisonOperator`. May be null for Alertmanager alarms and some GCP conditions." - threshold: Float - - "Evaluation window in seconds over which the metric is aggregated before the comparison is applied. May be null for alarms ingested from providers that don't expose a period." - period: Int - - "The cloud metric this alarm evaluates. May be null for alarms from providers that don't supply structured metric data (e.g., Alertmanager)." - metric: AlarmMetric - - "The most recent state reported for this alarm. `null` indicates no state has been recorded yet." - currentState: AlarmState - - "When this alarm was registered with Massdriver (UTC)." - createdAt: DateTime! - - "When this alarm's configuration last changed (UTC)." - updatedAt: DateTime! -} - -type AlarmsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type alarm." - items: [Alarm] -} - -type AlarmPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: Alarm -} - -"A key-value dimension identifying the cloud resource a metric applies to." -input AlarmMetricDimensionInput { - "Dimension name as defined by the cloud provider." - name: String! - - "Dimension value identifying the monitored resource." - value: String! -} - -""" -The cloud metric an alarm evaluates. - -Most fields are optional because availability depends on the cloud provider. -Pass `dimensions: []` if the provider doesn't expose structured dimensions -for this metric. -""" -input AlarmMetricInput { - "Cloud service namespace that categorizes the metric (e.g., `AWS\/RDS`)." - namespace: String - - "Metric name within the namespace (e.g., `CPUUtilization`)." - name: String - - "Aggregation function applied to samples (e.g., `Average`). Optional for providers without it." - statistic: String - - "Cloud region the metric is scoped to, when applicable." - region: String - - "Dimensions identifying the monitored resource. Omit or pass an empty list when the cloud provider doesn't expose structured dimensions." - dimensions: [AlarmMetricDimensionInput!] -} - -"Register a cloud metric alarm with an instance. The alarm appears in the UI immediately and receives state transitions as soon as the cloud provider reports them. Webhooks from AWS CloudWatch, Azure Monitor, GCP Cloud Monitoring, and Prometheus Alertmanager match against `cloudResourceId` to attach state." -input CreateInstanceAlarmInput { - "The cloud provider's unique identifier for the alarm. Used to correlate incoming state transition webhooks back to this alarm. Examples: a CloudWatch AlarmArn, a GCP alert policy name, an Azure alert id." - cloudResourceId: String! - - "How the metric is compared against `threshold` (e.g., `GREATER_THAN`, `LESS_THAN`). Optional for providers that don't expose this concept (e.g., Alertmanager, GCP)." - comparisonOperator: String - - "Human-readable name for the alarm. Shown in the UI and in notifications. Typically the alarm name registered with the cloud provider." - displayName: String! - - "The cloud metric this alarm evaluates. Optional; not all providers expose structured metric data." - metric: AlarmMetricInput - - "Evaluation window in seconds over which the metric is aggregated before the comparison is applied. Optional." - period: Int - - "The value crossed to trigger the alarm. Compared against the metric using `comparisonOperator`. Optional." - threshold: Float -} - -"Update a registered alarm's mutable fields. Omit a field to leave it unchanged." -input UpdateInstanceAlarmInput { - "The cloud provider's unique identifier for the alarm. Updating this changes which incoming webhooks correlate to this alarm." - cloudResourceId: String - - "How the metric is compared against `threshold` (e.g., `GREATER_THAN`, `LESS_THAN`)." - comparisonOperator: String - - "Human-readable name for the alarm. Shown in the UI and in notifications." - displayName: String - - "The cloud metric this alarm evaluates." - metric: AlarmMetricInput - - "Evaluation window in seconds over which the metric is aggregated." - period: Int - - "The value crossed to trigger the alarm." - threshold: Float -} - -""" -Narrows the instance alarms list to only matching records. - -All filters are combined with AND logic. Omit a filter to skip that criterion. -""" -input InstanceAlarmsFilter { - "Return only alarms in the specified project(s)." - projectId: IdFilter - - "Return only alarms in the specified environment(s)." - environmentId: IdFilter - - "Return only alarms attached to the specified component(s)." - componentId: IdFilter - - "Return only alarms for the specified instance(s)." - instanceId: IdFilter - - "Return only alarms on instances of the given bundle (e.g., `aws-rds`)." - ociRepoName: OciRepoNameFilter -} - -"Sorting options for the instance alarms list. Specify a field and direction." -input InstanceAlarmsSort { - "The field to sort by." - field: InstanceAlarmsSortField! - - "`ASC` for A-Z \/ oldest first, `DESC` for Z-A \/ newest first." - order: SortOrder! -} - -"Available fields for sorting instances." -enum InstancesSortField { - "Alphabetical by instance name (A-Z or Z-A)." - NAME - - "Chronological by creation time (oldest or newest first)." - CREATED_AT -} - -""" -The current lifecycle state of an instance. - -```mermaid -stateDiagram-v2 - [*] --> INITIALIZED: "Component added to environment" - INITIALIZED --> PROVISIONED: "Deployment succeeds" - INITIALIZED --> FAILED: "Deployment fails" - PROVISIONED --> PROVISIONED: "Redeploy / update" - PROVISIONED --> DECOMMISSIONED: "Decommission succeeds" - PROVISIONED --> FAILED: "Deployment fails" - FAILED --> PROVISIONED: "Retry succeeds" - FAILED --> DECOMMISSIONED: "Decommission" -``` -""" -enum InstanceStatus { - "The instance has been created but no deployment has started yet." - INITIALIZED - - "Infrastructure is successfully deployed and running." - PROVISIONED - - "Infrastructure has been torn down. The instance record is retained for audit purposes." - DECOMMISSIONED - - "The most recent deployment failed. Check deployment logs for details. Can be retried." - FAILED -} - -""" -Controls which bundle releases are eligible for deployment. - -The release strategy works in conjunction with the version constraint to -determine which bundle version is resolved for deployment. -""" -enum ReleaseStrategy { - "Only use stable, published releases. Recommended for production environments." - STABLE - - "Include pre-release\/development builds. Useful for testing unreleased bundle changes." - DEVELOPMENT -} - -"Available fields for sorting param dimensions." -enum ParamDimensionsSortField { - "Alphabetical by the dot-separated field path (e.g., `database.instance_type`)." - FIELD - - "Alphabetical by the human-readable label." - LABEL -} - -""" -A configuration parameter field extracted from instance bundle schemas. - -Param dimensions describe the filterable fields available across your instances. -Use the `paramDimensions` query to discover them, then pass matching values to -the `paramDimension` filter on the `instances` query to build infrastructure -search dashboards. -""" -type ParamDimension { - "jq-style path to the field (e.g., `.database.instance_type`). Pass this value unchanged as the `dimension` argument of `paramDimensionFilter`." - field: String! - - "Human-readable field name, sourced from the bundle schema's `title`." - label: String! - - "Explanation of what this field configures, sourced from the bundle schema." - description: String - - "JSON Schema data type (`string`, `number`, `boolean`, `integer`, `object`)." - type: String -} - -"Sorting options for the instances list." -input InstancesSort { - "The field to sort by." - field: InstancesSortField! - - "Ascending or descending." - order: SortOrder! -} - -""" -Filter which instances to return. - -All filters are combined with AND. For example, setting both `status` and -`ociRepoName` returns only instances that match both criteria. -""" -input InstancesFilter { - "Match by the owning project's ID." - projectId: IdFilter - - "Match by the environment's ID." - environmentId: IdFilter - - "Match by lifecycle status (e.g., `PROVISIONED`, `FAILED`)." - status: InstanceStatusFilter - - "Match by the OCI repository name of the underlying bundle." - ociRepoName: OciRepoNameFilter - - "Filter by configuration parameter values. Each entry targets a specific param field; multiple entries are combined with AND." - paramDimension: [ParamDimensionFilter!] -} - -""" -Scope which instances' bundle schemas are introspected for param dimensions. - -Use these filters to narrow the universe of instances before extracting -their configuration fields. Without filters, all accessible instances are considered. -""" -input ParamDimensionsFilter { - "Only include dimensions from instances in the specified projects." - projectId: IdFilter - - "Only include dimensions from instances in the specified environments." - environmentId: IdFilter - - "Only include dimensions from instances using the specified bundles." - ociRepoName: OciRepoNameFilter -} - -"Sorting options for the param dimensions list." -input ParamDimensionsSort { - "The field to sort by." - field: ParamDimensionsSortField! - - "Ascending or descending." - order: SortOrder! -} - -"Update an instance's version constraint or release strategy. Changes take effect on the next deployment." -input UpdateInstanceInput { - "Whether to use stable or development releases" - releaseStrategy: ReleaseStrategy - - "Version constraint for the bundle (e.g., '~1.0', '1.2.3', 'latest'). Resolved against available releases." - version: String -} - -"Create or update a secret on an instance. The secret value is encrypted at rest and never returned in API responses." -input SetInstanceSecretInput { - "The secret name, as defined in the bundle's massdriver.yaml" - name: String! - - "The secret value. Will be encrypted at rest." - value: String! -} - -"Copy configuration from one instance to another. The source and destination must be instances of the same component. Source params (minus any fields marked non-copyable in the bundle) are written to the destination, then a plan deployment is created on the destination so the changes can be reviewed before applying." -input CopyInstanceInput { - "When true, copies remote resource references from the source instance to the destination. Defaults to false." - copyRemoteReferences: Boolean - - "When true, copies secret values from the source instance to the destination. Defaults to false." - copySecrets: Boolean - - "An optional message attached to the plan deployment created on the destination, similar to a commit message." - message: String - - "Optional overrides that are deep-merged onto the source params before writing to the destination. Useful for tweaking environment-specific values (e.g., instance sizes)." - overrides: Map -} - -""" -Metadata about an encrypted secret attached to an instance. - -Secrets are encrypted key-value pairs injected at deploy time. The API -never returns secret values -- only the name, fingerprint, and timestamps are exposed. -""" -type InstanceSecret { - "The secret's key name, used to reference it in deployment configuration." - name: String! - - "Lowercase hex SHA-256 of the stored value. Use as a stable fingerprint to detect changes without exposing the secret itself." - sha256: String! - - "When this secret was first created (UTC)." - createdAt: DateTime! - - "When this secret's value was last changed (UTC)." - updatedAt: DateTime! -} - -""" -Definition of a secret expected by an instance's bundle, with the stored value's -fingerprint when one has been set. - -Bundles declare the secrets they consume in their `app.secrets` manifest. Each -field describes one expected secret. The `sha256` fingerprint is null when no -value has been stored, and the lowercase hex SHA-256 of the stored value otherwise -- -use it to render set/unset state and to dirty-check edits client-side. Secret values -themselves are never returned by the API. -""" -type InstanceSecretField { - "The secret's key name (typically an environment variable name like `DATABASE_PASSWORD`). Use this name when calling `setInstanceSecret`." - name: String! - - "Whether this secret must be set before the instance can be deployed." - required: Boolean! - - "Human-readable display name shown in the UI." - title: String - - "Explanation of what this secret is used for." - description: String - - "Lowercase hex SHA-256 of the stored value, or null when no value has been set. Use null vs non-null to detect whether the secret is set." - sha256: String -} - -type InstancePayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: Instance -} - -type InstanceSecretPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: InstanceSecret -} - -""" -An output resource produced by an instance, keyed by the field handle that produced it. - -Resources are the outputs an instance publishes after a successful deployment -(e.g., a database connection string, a Kubernetes cluster endpoint). Other -instances can consume these resources via connections. -""" -type InstanceResource { - "The output handle name that produced this resource (e.g., `authentication`)." - field: String! - - "The resource containing the actual data." - resource: Resource! - - "Whether the bundle guarantees this output is produced on every successful deployment." - required: Boolean! - - "The resource type (artifact definition) this output produces." - resourceType: ResourceType! -} - -""" -Where a dependency wire-in comes from. - -- `Connection` — the wire was drawn from a blueprint Link between two - components in this project. -- `RemoteReference` — the wire is a per-instance override pointing at a - resource from another project (or an imported resource). -- `EnvironmentDefault` — no explicit wire was set, so the slot is filled - from the environment's default for this resource type. - -Per-instance `RemoteReference` overrides take priority over blueprint -`Connection`s, which take priority over `EnvironmentDefault`s. -""" -union InstanceDependencySource = Connection | RemoteReference | EnvironmentDefault - -""" -An input dependency consumed by an instance, keyed by the field handle that receives it. - -Dependencies are resources wired into this instance's bundle slots — either -through a blueprint connection, a per-instance remote-reference override, or -the environment's default for the resource type. -""" -type InstanceDependency { - "The input handle name that consumes this resource (e.g., `database`)." - field: String! - - "The resource containing the actual data." - resource: Resource! - - "Whether this dependency must be connected before the instance can be deployed." - required: Boolean! - - "The resource type (artifact definition) this dependency slot accepts." - resourceType: ResourceType! - - "Where this slot's wire-in comes from. Inspect the concrete type — `Connection`, `RemoteReference`, or `EnvironmentDefault` — to distinguish." - source: InstanceDependencySource! -} - -""" -A Terraform/OpenTofu state path for a single deployment step. - -Bundles can define multiple provisioning steps (e.g., `core`, `iam`, `monitoring`). -Each step has its own state file managed by the Massdriver HTTP state backend. -""" -type InstanceStatePath { - "Full URL for this step's state file on the Massdriver HTTP state backend." - stateUrl: String! - - "The step's path identifier as defined in the bundle's `massdriver.yaml`." - stepName: String! -} - -""" -A flattened leaf value from one of this instance's resources. - -Each entry is a single scalar produced by the instance's deployments (e.g. a -database hostname, a queue URL). Values are drawn from both the instance's own -provisioned resources and any remote references wired in. - -Sensitive fields (marked `$md.sensitive: true` on the resource type's schema) -are replaced with `"[SENSITIVE]"`. -""" -type InstanceProperty { - "Display label built from the resource's title and the field's title (e.g. `Database: Hostname`)." - name: String! - - "jq-style path to the value from the instance root. Identifier-safe keys render as `.key`; keys with special characters are quoted (`.\"key.with.dots\"`); array elements use `[n]`. Examples: `.database.port`, `.cluster.nodes[0].host`, `.labels.\"app.kubernetes.io\/name\"`." - path: String! - - "Scalar value at this path, serialized as a string. Null if the underlying value is null. Sensitive fields appear as `[SENSITIVE]`." - value: String -} - -""" -A deployed piece of infrastructure in an environment. - -An instance is the **runtime representation** of a component. When you add a -"database" component to your blueprint and deploy it to the `staging` -environment, Massdriver creates an instance that tracks the database's -configuration, deployment state, costs, and produced resources. - -**Lifecycle:** Instances progress through a well-defined set of states: - -```mermaid -stateDiagram-v2 - [*] --> INITIALIZED: "Component added to environment" - INITIALIZED --> PROVISIONED: "Deployment succeeds" - INITIALIZED --> FAILED: "Deployment fails" - PROVISIONED --> PROVISIONED: "Redeploy / update" - PROVISIONED --> DECOMMISSIONED: "Decommission succeeds" - PROVISIONED --> FAILED: "Deployment fails" - FAILED --> PROVISIONED: "Retry succeeds" - FAILED --> DECOMMISSIONED: "Decommission" -``` - -**Version resolution:** Each instance has a `version` constraint (e.g., `~1.0`) -and a `releaseStrategy` (stable or development). Together these determine -the `resolvedVersion` that will be used on the next deployment. Compare -`resolvedVersion` with `deployedVersion` to see if a redeployment is needed, -or check `availableUpgrade` for newer matching releases. -""" -type Instance { - id: ID! - - "Human-readable display name for the instance." - name: String! - - "Current lifecycle state of the instance." - status: InstanceStatus! - - "Cached configuration parameters from the most recent deployment. Null if the instance has never been deployed." - params: Map - - """ - JSON Schema describing the configuration parameters this instance accepts. - - The schema is sourced from the instance's resolved bundle release with - Massdriver's `$md` extensions evaluated against the current instance state: - - - `$md.enum` is replaced with a `oneOf` list whose entries are computed by - running the configured jq expression against the connected resource's - payload. Missing connections or jq errors produce a single placeholder - entry whose `title` begins with `ERROR:`. - - `$md.immutable` is rewritten to `readOnly: true` once the instance has - reached a state where the field can no longer be changed (`PROVISIONED` - or `FAILED`). - - Use this schema to drive form rendering, client-side validation, or to - inspect the contract between the bundle and the deployer. - """ - paramsSchema: Map! - - """ - UI hints describing how to render the params form. - - Follows [react-jsonschema-form](https://rjsf-team.github.io/react-jsonschema-form/)'s - `uiSchema` conventions: keys mirror the params schema's structure and values - contain rendering directives (e.g., `ui:widget`, `ui:order`, `ui:help`). - Returns an empty object when the bundle does not provide UI hints. - """ - uiSchema: Map! - - """ - Definitions of the secrets this instance's bundle expects, sorted by name. - - Each entry pairs the bundle's declared field (name, required flag, optional title / - description) with the stored value's `sha256` fingerprint when one has been set. - Use null vs non-null `sha256` to render set/unset state. Secret values are never - returned by the API. - """ - secretFields: [InstanceSecretField!]! - - """ - Operator guide for this instance, rendered with the current instance state. - - The bundle's raw guide is plain markdown that may include YAML front matter - selecting a templating engine (`mustache` or `liquid`). When templating is - enabled, the body is rendered with a context exposing the instance's - `id`, `params`, connected `connections` payloads, and produced `artifacts` - payloads -- with sensitive fields masked as `[SENSITIVE]`. When templating - is not declared (or the engine is unsupported), the raw guide is returned - unchanged. Returns null when the bundle does not provide an operator guide. - """ - operatorGuide: String - - "Key-value attributes assigned directly to this instance." - attributes: Map! - - """ - The full attribute map the authorization system evaluates policies against for - this instance — user attributes merged across the hierarchy plus auto-injected - `md-*` system attributes. - - User-attribute merge precedence (higher overrides lower): project > environment > component > instance. - - System attributes always present on an instance: - - `md-id` — the instance's identifier - - `md-project` — the project's identifier - - `md-environment` — the environment's local identifier - - `md-component` — the component's local identifier - - `md-repo` — the bundle's repo name - - `md-bundle` — `"{bundle}@{version}"` of the resolved release - """ - effectiveAttributes: Map! - - "Version constraint that controls which bundle releases are eligible. Supports semver constraints like `~1.0`, exact versions like `1.2.3`, or `latest`." - version: String! - - "Whether to include development (pre-release) builds when resolving the version constraint." - releaseStrategy: ReleaseStrategy! - - "When this instance was created (UTC)." - createdAt: DateTime! - - "When this instance was last modified (UTC)." - updatedAt: DateTime! - - """ - The concrete bundle version resolved from the version constraint and release strategy. - - This is the version that will be used on the **next** deployment. Compare - with `deployedVersion` to determine if a redeployment would change anything. - """ - resolvedVersion: String! - - """ - The bundle version that was last successfully deployed to infrastructure. - - May differ from `resolvedVersion` if the version constraint has been updated - but no deployment has occurred yet. Null if the instance has never been deployed. - """ - deployedVersion: String - - """ - The newest bundle version available that satisfies the version constraint. - - Returns null if the instance is already on the latest matching version. - Use this field to detect when an upgrade is available. - """ - availableUpgrade: String - - "Cloud provider cost summary for this instance, including daily and monthly breakdowns." - cost: CostSummary! - - "The environment this instance is deployed in." - environment: Environment - - "The bundle release currently resolved for this instance." - bundle: Bundle - - "The component this instance was deployed from." - component: Component! - - """ - Terraform/OpenTofu state paths for each provisioning step, ordered by the bundle's step definition. - - Each bundle can define multiple steps (e.g., `core`, `iam`, `monitoring`). Use the - `stateUrl` to configure your Terraform backend or inspect state externally. - """ - statePaths: [InstanceStatePath!]! - - """ - Flattened list of scalar leaf values published by this instance's resources. - - Each entry corresponds to one scalar in a resource's payload (e.g. a database - hostname, a queue URL). Entries are drawn from both provisioned resources and - any remote references set on this instance. - - Sensitive fields (marked `$md.sensitive: true` on the resource type's schema) - are returned as `"[SENSITIVE]"`. Paths are jq-style — identifier-safe keys as - `.key`, non-identifier keys quoted (`."app.kubernetes.io/name"`), array - elements as `[n]` (e.g. `.cluster.nodes[0].host`). - """ - properties: [InstanceProperty!]! - - """ - Resources produced by this instance, sorted alphabetically by field. - - Resources are the outputs published after a successful deployment - (e.g., connection strings, endpoints, credentials). Other instances consume - these resources via connections. - """ - resources: [InstanceResource!]! - - """ - Paginated list of cloud metric alarms configured for this instance. - - Alarms are provisioned by the Massdriver Terraform/OpenTofu provider and - receive state updates via webhooks from AWS CloudWatch, Azure Monitor, - GCP Cloud Monitoring, or Prometheus Alertmanager. Inspect `currentState` - to see whether an alarm is firing; a `null` `currentState` means no state - has been reported yet. - """ - alarms( - "Pagination cursor returned by a previous page." - cursor: Cursor - ): AlarmsPage - - """ - Dependencies wired into this instance's bundle slots, sorted alphabetically by field. - - Each entry is one filled slot from the bundle's `connections_schema` along - with the source object that filled it — a blueprint `Connection`, a - per-instance `RemoteReference`, or an `EnvironmentDefault` from the - environment. Unfilled slots are not included. - """ - dependencies: [InstanceDependency!]! - - """ - Whether this instance can be safely decommissioned right now. Check `constraints` for blocking conditions. - - Decommissioning tears down the instance's provisioned cloud infrastructure and - moves the instance to `DECOMMISSIONED`. Decommissioning is blocked while another - instance is consuming this instance's resources, or while an environment default - is pinned to one of those resources. - """ - decommissionable: Deletable! -} - -type InstancesPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type instance." - items: [Instance] -} - -type ParamDimensionsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type param_dimension." - items: [ParamDimension] -} - -"Available fields for sorting connections." -enum ConnectionsSortField { - "Chronological by creation time (oldest or newest first)." - CREATED_AT -} - -"Sorting options for the connections list." -input ConnectionsSort { - "The field to sort by." - field: ConnectionsSortField! - - "Ascending or descending." - order: SortOrder! -} - -"Filter which connections to return." -input ConnectionsFilter { - "Match by the source (from) instance's ID." - fromInstanceId: IdFilter - - "Match by the destination (to) instance's ID." - toInstanceId: IdFilter -} - -""" -A runtime wiring between two instances in an environment. - -A connection is the **runtime realization** of a blueprint link. Where a link -says "the database component's `authentication` output goes to the app -component's `database` input," the connection in each environment carries the -*actual* resource data (e.g., a connection string) from the source instance -to the destination instance. - -Connections are created automatically when instances are deployed and a -matching blueprint link exists. -""" -type Connection { - "Unique identifier for this connection." - id: ID! - - "The output field name on the source instance that produces the resource." - fromField: String! - - "The input field name on the destination instance that consumes the resource." - toField: String! - - "When this connection was created (UTC)." - createdAt: DateTime! - - "When this connection was last modified (UTC)." - updatedAt: DateTime! - - "The source instance that produces the resource wired through this connection." - fromInstance: Instance - - "The destination instance that consumes the resource wired through this connection." - toInstance: Instance - - "The blueprint link that this connection realizes. Null if the link has since been removed." - link: Link -} - -""" -An environment's realized infrastructure graph. - -The environment blueprint is the **runtime counterpart** of the project's -design-time blueprint. It contains the **instances** (deployed infrastructure) -and **connections** (runtime wiring) that show exactly how your infrastructure -is running in this environment. - -While the project blueprint defines the architecture once, each environment -has its own environment blueprint with independent instances and connections. -This means `staging` and `production` can run different versions, different -configurations, or even different subsets of the full blueprint. -""" -type EnvironmentBlueprint { - """ - Paginated list of instances deployed in this environment. - - Returns all deployed infrastructure for the environment. Filter by status, - bundle name, or configuration parameters. Defaults to alphabetical order by name. - """ - instances( - "Narrow results by status, bundle, or configuration values." - filter: InstancesFilter - - "Sort field and direction. Defaults to `name` ascending." - sort: InstancesSort - - "Pagination cursor returned by a previous page." - cursor: Cursor - ): InstancesPage - - """ - Paginated list of connections between instances in this environment. - - Each connection represents a runtime data flow from one instance's output - to another instance's input. Defaults to chronological order by creation time. - """ - connections( - "Narrow results by source or destination instance." - filter: ConnectionsFilter - - "Sort field and direction. Defaults to `created_at` ascending." - sort: ConnectionsSort - - "Pagination cursor returned by a previous page." - cursor: Cursor - ): ConnectionsPage -} - -type ConnectionsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type connection." - items: [Connection] -} - -"Fields available for sorting the environments list." -enum EnvironmentsSortField { - "Sort alphabetically by environment name (A-Z or Z-A)." - NAME - - "Sort by creation date (oldest first or newest first)." - CREATED_AT -} - -"Sorting options for the environments list. Specify a field and direction." -input EnvironmentsSort { - "The field to sort by." - field: EnvironmentsSortField! - - "`ASC` for A-Z \/ oldest first, `DESC` for Z-A \/ newest first." - order: SortOrder! -} - -"Filters for narrowing the environments list. All filters are optional and combine with AND logic." -input EnvironmentsFilter { - "Filter to environments belonging to a specific project." - projectId: IdFilter - - "Filter by environment identifier (supports exact match and `in` list)." - id: StringFilter -} - -"Create a new environment. Environments are isolated deployment contexts like production, staging, or development, each with independent secrets and configurations." -input CreateEnvironmentInput { - "Key-value attributes for this environment. Keys and values must be strings. Must conform to the organization's custom attributes for the environment scope." - attributes: Map - - "An optional description of the environment's purpose" - description: String - - "A short, memorable identifier for looking up this environment in the API and CLI. This becomes the second segment of package identifiers. For example, project 'ecomm' with environment 'prod' and component 'db' creates 'ecomm-prod-db'. Use familiar names like 'prod', 'staging', 'dev'—human-readable, not a UUID. Max 20 characters, lowercase alphanumeric only (a-z, 0-9). Immutable after creation." - id: String! - - "A human-readable name for the environment" - name: String! -} - -"Update an existing environment's name and description. The ID cannot be changed after creation." -input UpdateEnvironmentInput { - "Key-value attributes for this environment. Keys and values must be strings. Must conform to the organization's custom attributes for the environment scope." - attributes: Map - - "An optional description of the environment's purpose" - description: String - - "A human-readable name for the environment" - name: String -} - -"Attributes for the new environment. The fork references the parent via `parentId`, starts with blank instances, and does not copy any instance-level configuration. Use `copyInstance` per-instance if you want to seed configuration from the parent." -input ForkEnvironmentInput { - "Key-value attributes for this environment. Keys and values must be strings. Must conform to the organization's custom attributes for the environment scope." - attributes: Map - - "When true, copies the parent environment's default resource connections into the fork. Instance-level configuration is never copied — use `copyInstance` to seed instance params." - copyEnvironmentDefaults: Boolean - - "An optional description of the forked environment's purpose" - description: String - - "A short, memorable identifier for looking up this environment in the API and CLI. This becomes the second segment of instance identifiers. Max 20 characters, lowercase alphanumeric only (a-z, 0-9). Immutable after creation." - id: String! - - "A human-readable name for the forked environment" - name: String! -} - -""" -A deployment target within a project where blueprint components become live infrastructure. - -Each project can have multiple environments (e.g., `staging`, `production`). When you deploy -to an environment, every component in the project's blueprint is realized as an **Instance** -- -a running piece of cloud infrastructure with its own configuration, state, and cost data. - -Environments inherit attributes from their parent project. You can also set environment-scoped attributes -that cascade down to all instances within the environment. **Defaults** let you pre-assign -resources (like a shared VPC or DNS zone) so that new instances automatically receive them. - -Before deleting an environment, all instances must be decommissioned. Use the `deletable` -field to check for blocking constraints. -""" -type Environment { - id: ID! - - "Display name shown in the UI and CLI. Must be unique within the project." - name: String! - - "Free-text description of what this environment is for." - description: String - - "Key-value attributes assigned directly to this environment. Attributes cascade to instances. Must conform to your organization's custom attributes for the `ENVIRONMENT` scope." - attributes: Map! - - """ - The full attribute map the authorization system evaluates policies against for - this environment — user attributes merged with the parent project (project wins on - conflict) plus auto-injected `md-*` system attributes. - - System attributes always present on an environment: - - `md-id` — the environment's identifier - - `md-project` — the project's identifier - - `md-environment` — the environment's local identifier - """ - effectiveAttributes: Map! - - "When this environment was created (UTC)." - createdAt: DateTime! - - "When this environment was last modified (UTC)." - updatedAt: DateTime! - - "The parent project that this environment belongs to." - project: Project - - "Aggregated cloud-provider cost metrics for all instances in this environment." - cost: CostSummary! - - "Whether this environment can be safely deleted. Check `constraints` for blocking conditions." - deletable: Deletable! - - "The realized infrastructure blueprint showing deployed instances and their connections in this environment." - blueprint: EnvironmentBlueprint - - """ - Paginated list of default resources for this environment. - - Defaults are pre-assigned resources (like a shared VPC or DNS zone) that instances - automatically inherit when they require a matching resource type. Only one default - per resource type is allowed. - """ - defaults( - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): EnvironmentDefaultsPage -} - -""" -A resource referenced by an environment default. - -This represents the actual cloud resource (e.g., a VPC or DNS zone) that has been -designated as the default for its resource type within an environment. -""" -type EnvironmentDefaultResource { - "The resource's unique identifier." - id: ID! - - "Human-readable name of the resource." - name: String! - - "The resource type (e.g., `massdriver\/aws-vpc`) that this resource conforms to." - resourceType: ResourceType -} - -""" -An environment default that automatically provides a resource to instances. - -When an instance in the environment requires a resource type that matches this default, -the resource is automatically connected without manual configuration. Only one default -per resource type is allowed per environment -- remove the existing default before -setting a new one. -""" -type EnvironmentDefault { - "Unique identifier for this environment default." - id: ID! - - "The resource that is set as the default for its type." - resource: EnvironmentDefaultResource! - - "The environment in which this resource is the default for its type." - environment: Environment! - - "When this default was first set (UTC)." - createdAt: DateTime! - - "When this default was last modified (UTC)." - updatedAt: DateTime! -} - -type EnvironmentDefaultsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type environment_default." - items: [EnvironmentDefault] -} - -type EnvironmentDefaultPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: EnvironmentDefault -} - -type EnvironmentsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type environment." - items: [Environment] -} - -type EnvironmentPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: Environment -} - -""" -A per-component comparison between two environments. - -Instances are paired across environments by their underlying component. -When only one side has an instance for a given component, the other -side's `source`/`target` is `null`, and every param appears as present -on the populated side only. -""" -type InstanceComparison { - "The component shared (or would-be-shared) by the two instances being compared." - component: Component! - - "The instance on the source environment, or `null` if the component is not deployed there." - source: Instance - - "The instance on the target environment, or `null` if the component is not deployed there." - target: Instance - - "The instance's resolved version on each side, with an `equal` flag." - version: VersionComparison! - - "Flat, leaf-level diff of the two instances' configured params." - params: [ParamComparison!]! - - "`true` when both instances are present, the versions match, and every param is equal." - equal: Boolean! -} - -""" -Side-by-side comparison of two environments in the same project. - -Returned by `compareEnvironments`. The comparison pairs instances by -component and reports a per-instance diff of the resolved version and -configured params. Environment-level attributes and default resource wiring -are intentionally out of scope. - -Environments must belong to the same project — cross-project comparisons -are not meaningful because components are project-scoped. -""" -type EnvironmentComparison { - "The environment on the source side of the comparison." - source: Environment! - - "The environment on the target side of the comparison." - target: Environment! - - "Per-component diff, sorted by component identifier for a stable output." - instances: [InstanceComparison!]! -} - -"Fields available for sorting the projects list." -enum ProjectsSortField { - "Sort alphabetically by project name (A-Z or Z-A)." - NAME - - "Sort by creation date (oldest first or newest first)." - CREATED_AT -} - -"Sorting options for the projects list. Specify a field and direction." -input ProjectsSort { - "The field to sort by." - field: ProjectsSortField! - - "Sort direction (`ASC` or `DESC`)." - order: SortOrder! -} - -"Create a new project. A project is the complete model of your application—its infrastructure, architecture, configurations, and environments." -input CreateProjectInput { - "Key-value attributes for this project. Keys and values must be strings. Must conform to the organization's custom attributes for the project scope." - attributes: Map - - "An optional description of the project's purpose or contents" - description: String - - "A short, memorable identifier for looking up this project in the API and CLI. This becomes the first segment of all resource identifiers within the project. For example, a project 'ecomm' with environment 'prod' and component 'db' creates the package identifier 'ecomm-prod-db'. Choose something concise and meaningful—human-readable, not a UUID. Max 20 characters, lowercase alphanumeric only (a-z, 0-9). Immutable after creation." - id: String! - - "A human-readable name for the project" - name: String! -} - -"Update an existing project's name and description. The ID cannot be changed after creation." -input UpdateProjectInput { - "Key-value attributes for this project. Keys and values must be strings. Must conform to the organization's custom attributes for the project scope." - attributes: Map - - "An optional description of the project's purpose or contents" - description: String - - "A human-readable name for the project" - name: String -} - -"Attributes for the new project." -input CloneProjectInput { - "Key-value attributes for this project. Keys and values must be strings. Must conform to the organization's custom attributes for the project scope." - attributes: Map - - "An optional description of the project's purpose or contents" - description: String - - "A short, memorable identifier for looking up this project in the API and CLI. This becomes the first segment of all resource identifiers within the project. Max 20 characters, lowercase alphanumeric only (a-z, 0-9). Immutable after creation." - id: String! - - "A human-readable name for the new project" - name: String! -} - -""" -A project organizes related infrastructure under a single blueprint. - -Each project contains a **Blueprint** that defines your infrastructure architecture -- which -bundles to use and how they connect -- and one or more **Environments** (like staging or -production) where that architecture is actually deployed. - -```mermaid -graph LR - P["Project"] --> B["Blueprint"] - P --> E1["Environment: staging"] - P --> E2["Environment: production"] - B --> C1["Component: database"] - B --> C2["Component: cache"] - C1 -.->|"Link"| C2 -``` - -Attributes set on a project are inherited by all environments and instances within it. -""" -type Project { - id: ID! - - "Display name shown in the UI and CLI. Must be unique within the organization." - name: String! - - "Free-text description of what this project is for." - description: String - - "Key-value attributes assigned directly to this project. Attributes cascade to environments and instances. Must conform to your organization's custom attributes for the `PROJECT` scope." - attributes: Map! - - """ - The full attribute map the authorization system evaluates policies against for - this project — the project's own user attributes plus auto-injected `md-*` system attributes. - - System attributes always present on a project: - - `md-id` — the project's identifier - - `md-project` — the project's identifier - """ - effectiveAttributes: Map! - - "When this project was created (UTC)." - createdAt: DateTime! - - "When this project was last modified (UTC)." - updatedAt: DateTime! - - "Whether this project can be safely deleted. Check `constraints` for blocking conditions." - deletable: Deletable! - - "Aggregated cloud-provider cost metrics for all instances in this project." - cost: CostSummary! - - "Paginated list of environments in this project (e.g., staging, production)." - environments( - "How to sort results. Defaults to alphabetical by name." - sort: EnvironmentsSort - - "Cursor from a previous page to fetch the next set of results." - cursor: Cursor - ): EnvironmentsPage - - "The infrastructure blueprint defining this project's components and their connections." - blueprint: Blueprint -} - -type ProjectsPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type project." - items: [Project] -} - -type ProjectPayload { - "Indicates if the mutation completed successfully or not." - successful: Boolean! - - "A list of failed validations. May be blank or null if mutation succeeded." - messages: [ValidationMessage] - - "The object created\/updated\/deleted by the mutation. May be null if mutation failed." - result: Project -} - -""" -A fully resolved [semantic version](https://semver.org/) string. - -This scalar always represents a concrete, published version -- never a constraint -or channel. It appears wherever the API returns or accepts an exact version. - -**Stable versions** follow the `MAJOR.MINOR.PATCH` pattern: - - 1.2.3 - -**Development versions** append a `-dev.TIMESTAMP` pre-release suffix: - - 1.2.3-dev.20060102T150405Z - -If you need to specify a version range or auto-resolving channel instead of an -exact version, use the `ReleaseChannel` or `VersionConstraint` scalars. -""" -scalar Semver - -""" -A composite bundle identifier in **`name@version`** format. - -This scalar uniquely identifies a bundle by combining its OCI repository name -with a version specifier. As an **output** the version portion is always a -fully resolved semver string: - - aws-aurora-postgres@1.2.3 - aws-aurora-postgres@1.2.3-dev.20060102T150405Z - -As an **input** (e.g., in the `bundle` query), you can use any of the following -formats and the server will resolve to the best matching published version: - -| Format | Example | Resolves to | -|--------|---------|-------------| -| Bare name | `aws-aurora-postgres` | Latest stable (or latest dev if no stable exists) | -| Exact version | `aws-aurora-postgres@1.2.3` | That exact version | -| Major channel | `aws-aurora-postgres@~1` | Latest `1.x.x` stable | -| Minor channel | `aws-aurora-postgres@~1.2` | Latest `1.2.x` stable | -| Latest stable | `aws-aurora-postgres@latest` | Newest stable release | -| Latest + dev | `aws-aurora-postgres@latest+dev` | Newest release including dev builds | -""" -scalar BundleId - -""" -A release channel identifier for automatic version tracking. - -Release channels are version constraints that always resolve to the latest -matching published version. When you pin a component to a release channel, -it automatically picks up new compatible versions as they are published. - -**Stable channels** (exclude development builds): - -| Channel | Matches | -|---------|---------| -| `latest` | Newest stable release across all major versions | -| `~1` | Latest minor + patch in the `1.x.x` series | -| `~1.2` | Latest patch in the `1.2.x` series | - -**Development channels** (include `-dev.*` pre-release builds): - -| Channel | Matches | -|---------|---------| -| `latest+dev` | Newest release of any kind | -| `~1+dev` | Latest in `1.x.x` including dev builds | -| `~1.2+dev` | Latest in `1.2.x` including dev builds | -""" -scalar ReleaseChannel - -""" -A union of exact versions and release channels. - -Use this scalar anywhere you need to specify either a pinned version or an -auto-resolving channel. It accepts every format that `Semver` and -`ReleaseChannel` accept: - -**Exact versions:** - -- `1.2.3` -- a specific stable release -- `1.2.3-dev.20060102T150405Z` -- a specific development build - -**Release channels:** - -- `latest` -- newest stable release -- `latest+dev` -- newest release including dev builds -- `~1` -- latest minor + patch in the `1.x.x` series -- `~1.2` -- latest patch in the `1.2.x` series -- `~1+dev` / `~1.2+dev` -- same ranges including dev builds - -This is the most permissive version scalar. Use `Semver` when only exact -versions are valid, or `ReleaseChannel` when only channels are valid. -""" -scalar VersionConstraint - -""" -Fields available for ordering the bundles list. - -When a `search` filter is active and no explicit sort is provided, results are -ranked by relevance instead of these fields. -""" -enum BundlesSortField { - "Alphabetical order by the bundle's OCI repository name (e.g., `aws-aurora-postgres`)." - NAME - - "Chronological order by the date this bundle version was published." - CREATED_AT -} - -""" -Controls the sort order of the bundles list. - -When a `search` filter is active and no sort is provided, results are ranked by -relevance. Providing an explicit sort overrides relevance ranking. -""" -input BundlesSort { - "The field to order results by." - field: BundlesSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -""" -Filters for narrowing the bundles list. - -All filters are combined with AND logic. For example, setting both `ociRepo` -and `resourceType` returns only bundles in the specified repository that also -produce the given resource type. -""" -input BundlesFilter { - "Restrict results to bundles published under a specific OCI repository name." - ociRepo: OciRepoNameFilter - - "Return only bundles that **produce** a resource of the given type (e.g., `dynamodb-table`, `aws-iam-role`)." - resourceType: StringFilter - - "Return only bundles that **require** a dependency of the given type (e.g., `aws-iam-role`, `kubernetes-cluster`)." - dependencyType: StringFilter - - "Full-text search across the bundle's name, description, operator guide, and readme. Results are ranked by relevance unless you provide an explicit `sort`. For terms longer than 3 characters, name-prefix matches are also included. **Note:** pagination cursors returned by search results use offset-based pagination and are not interchangeable with cursors from non-search queries." - search: String -} - -""" -An input that a bundle requires from another bundle's output. - -Each dependency declares a named slot that must (or may) be satisfied by -connecting it to a resource of the matching type. For example, a database -bundle might declare an `aws_authentication` dependency of type `aws-iam-role`. - -Dependencies are satisfied at deploy time by linking an instance that produces -the required resource type, or by an environment-level default if the resource -type's connection orientation is `ENVIRONMENT_DEFAULT`. -""" -type BundleDependency { - "Handle name for this dependency slot (e.g., `aws_authentication`, `vpc`)." - name: String! - - "When `true`, this dependency must be connected before the bundle can be deployed." - required: Boolean! - - "The resource type this dependency accepts. `null` if the resource type has been removed from the catalog." - resourceType: ResourceType -} - -""" -An output that a bundle produces when deployed. - -Resources represent the infrastructure outputs a bundle creates. Other -bundles can consume these as dependencies. For example, an Aurora Postgres -bundle might produce a `database` resource of type `aws-rds-instance` that -application bundles can connect to. -""" -type BundleResource { - "Handle name for this resource output (e.g., `database`, `cluster`)." - name: String! - - "When `true`, this resource is always produced on a successful deployment." - required: Boolean! - - "The resource type this output produces. `null` if the resource type has been removed from the catalog." - resourceType: ResourceType -} - -type BundlesPage { - "Pagination cursors for navigating between pages." - cursor: PaginationCursor! - - "A list of type bundle." - items: [Bundle] -} - -""" -A versioned infrastructure-as-code package. - -A bundle is a single published version of an IaC package in your organization's -catalog. Each bundle belongs to an OCI repository and is identified by a composite -`name@version` string (e.g., `aws-aurora-postgres@1.2.3`). - -Bundles declare **dependencies** (inputs they require from other bundles) and -**resources** (outputs they produce). These declarations drive the connection -system on the Massdriver canvas -- when you add a component to a blueprint, -the platform knows which other components can satisfy its dependencies. - -```mermaid -graph TD - R["OCI Repository: aws-aurora-postgres"] --> T1["Tag: 1.0.0"] - R --> T2["Tag: 1.1.0"] - R --> T3["Tag: 1.2.3"] - R --> RC1["Channel: ~1 → 1.2.3"] - R --> RC2["Channel: latest → 1.2.3"] - T3 --> B["Bundle: aws-aurora-postgres@1.2.3"] - B --> D1["Dependency: aws-iam-role"] - B --> D2["Dependency: aws-vpc"] - B --> RES["Resource: aurora-cluster"] -``` -""" -type Bundle { - "Composite identifier in `name@version` format (e.g., `aws-aurora-postgres@1.2.3`). Always contains the fully resolved semver version." - id: BundleId! - - "OCI repository name this bundle belongs to (e.g., `aws-aurora-postgres`)." - name: OciRepoName! - - "Fully resolved semantic version of this bundle (e.g., `1.2.3`)." - version: Semver! - - "Short summary of what this bundle provisions." - description: String - - "URL to the bundle's display icon." - icon: String - - "URL to the bundle's source code repository, if published by the author." - sourceUrl: String - - """ - The README for this exact bundle version, in markdown. `null` if the - publisher didn't include one. - - Use this when rendering documentation for a specific version — for the - latest stable README at the repository level, use `OciRepo.readme`. - """ - readme: String - - """ - The changelog for this exact bundle version, in markdown. `null` if the - publisher didn't include one. - - Each version's changelog is preserved — for the latest stable changelog - at the repository level, use `OciRepo.changelog`. - """ - changelog: String - - "Timestamp when this bundle version was first published (UTC)." - createdAt: DateTime! - - "Timestamp when this bundle version was last modified (UTC)." - updatedAt: DateTime! - - """ - The full set of attributes that ABAC policy evaluation sees for this - bundle version — the auto-injected `md-*` system attributes (today: - `md-id`, `md-repo`, `md-bundle`). Bundle versions are immutable - snapshots and do not carry user attributes of their own. - """ - effectiveAttributes: Map! - - "OCI repository name for this bundle (e.g., `aws-aurora-postgres`). Equivalent to `name`." - repo: String! - - "Dependencies (inputs) this bundle requires. Each entry names a slot and the resource type it accepts. Sorted alphabetically by name." - dependencies: [BundleDependency!]! - - "Resources (outputs) this bundle produces when deployed. Each entry names a slot and the resource type it creates. Sorted alphabetically by name." - resources: [BundleResource!]! -} - -""" -The short name of an OCI repository (e.g., `aws-aurora-postgres`). - -This scalar represents the human-readable repository name within your -organization's bundle catalog. It is used as the `id` for OCI repositories -and as the name portion of a `BundleId`. - -Repository names are unique within an organization and follow a lowercase -kebab-case convention (e.g., `aws-aurora-postgres`, `kubernetes-cluster`). -""" -scalar OciRepoName - -""" -The kind of artifact stored in an OCI repository. - -Each value maps to a concrete [OCI artifact type](https://github.com/opencontainers/image-spec/blob/main/manifest.md#guidelines-for-artifact-usage) -media string written to the manifest. Today only `BUNDLE` is supported; additional types will be added as the catalog expands. -""" -enum OciArtifactType { - "Massdriver bundle (`application\/vnd.massdriver.bundle.v1+json`)." - BUNDLE -} - -""" -Fields available for ordering the OCI repositories list. - -When a `search` filter is active and no explicit sort is provided, results are -ranked by relevance instead of these fields. -""" -enum OciReposSortField { - "Alphabetical order by repository name (e.g., `aws-aurora-postgres`)." - NAME - - "Chronological order by the date the repository was created." - CREATED_AT -} - -"Fields available for ordering tags within an OCI repository." -enum OciRepoTagsSortField { - "Semantic version order (major, then minor, then patch). Stable versions sort above dev pre-releases at the same version number." - VERSION - - "Chronological order by the date the tag was published." - CREATED_AT -} - -"Fields available for ordering release channels." -enum OciRepoReleaseChannelsSortField { - "Sort by channel name. Channels are ordered: `latest` first, then `~major` channels numerically, then `~major.minor` channels numerically. Dev (`+dev`) variants sort after their stable counterpart." - NAME -} - -"Fields available for ordering files within a tagged version of an OCI artifact." -enum OciFilesSortField { - "Alphabetical order by filename (e.g., `CHANGELOG.md`, `README.md`, `src\/main.tf`)." - NAME -} - -"Create a new OCI repository in your organization's catalog. Repositories must exist before any version can be published to them." -input CreateOciRepoInput { - "OCI artifact type stored in this repository. Today only `BUNDLE` is accepted; additional types will be added as Massdriver expands the catalog." - artifactType: OciArtifactType! - - "Key-value attributes for this repository. Used by ABAC policies for fine-grained access control. Reserved `md-*` keys are rejected. Must conform to the organization's custom attributes for the repo scope." - attributes: Map - - "Unique repository name within your organization, e.g. `aws-aurora-postgres`. Lowercase letters, numbers, dashes, underscores only. Max 53 characters. Cannot be changed after creation." - id: String! -} - -"Update an OCI repository's user-settable metadata. The repository name and artifact type are immutable." -input UpdateOciRepoInput { - "Replacement key-value attributes for this repository. Reserved `md-*` keys are rejected. Must conform to the organization's custom attributes for the repo scope." - attributes: Map -} - -""" -Filters for narrowing the OCI repositories list. - -All filters are combined with AND logic. -""" -input OciReposFilter { - "Filter by OCI artifact media type. Currently the only supported type is `application\/vnd.massdriver.bundle.v1+json`. Passing an unsupported type returns an empty list." - artifactType: String - - "Filter repositories by name using exact match, prefix, or set membership." - name: OciRepoNameFilter - - "Full-text search across the repository name, readme, and changelog. Results are ranked by relevance unless you provide an explicit `sort`. For terms longer than 3 characters, name-prefix matches are also included. **Note:** pagination cursors returned by search results use offset-based pagination and are not interchangeable with cursors from non-search queries." - search: String -} - -""" -Controls the sort order of the OCI repositories list. - -When a `search` filter is active and no sort is provided, results are ranked by -relevance. Providing an explicit sort overrides relevance ranking. -""" -input OciReposSort { - "The field to order results by." - field: OciReposSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -"Controls the sort order of tags within an OCI repository." -input OciRepoTagsSort { - "The field to order results by." - field: OciRepoTagsSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -""" -Filters for narrowing the tags list within an OCI repository. - -All filters are combined with AND logic. -""" -input OciRepoTagsFilter { - "Return only the tag with this exact version (e.g., `1.3.3`). When set, the result is at most one item." - version: Semver -} - -"Controls the sort order of files in a tagged version of an OCI artifact." -input OciFilesSort { - "The field to order results by." - field: OciFilesSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -"Filters for narrowing the release channels list." -input OciRepoReleaseChannelsFilter { - "Controls whether stable or development channels are returned. `true` returns only stable channels (`latest`, `~1`, `~1.2`). `false` returns only development channels (`latest+dev`, `~1+dev`, `~1.2+dev`). Omit to return all channels. When returning all channels, dev variants that resolve to the same version as their stable counterpart are automatically deduplicated." - stable: Boolean -} - -"Controls the sort order of release channels." -input OciRepoReleaseChannelsSort { - "The field to order results by." - field: OciRepoReleaseChannelsSortField! - - "Ascending (`ASC`) or descending (`DESC`)." - order: SortOrder! -} - -""" -A release channel within an OCI repository. - -Release channels are auto-resolving version constraints. Each channel points to -the latest published tag that matches its constraint. As new versions are -published, channels automatically update to point to the newest match. - -For example, channel `~1` always resolves to the highest `1.x.x` stable tag. -If `1.2.3` is the latest, the channel points there. When `1.3.0` is published, -the channel automatically advances. -""" -type OciRepoReleaseChannel { - "The channel constraint expression (e.g., `~1`, `~1.2`, `latest`, `latest+dev`)." - name: ReleaseChannel! - - "The fully resolved semver version this channel currently points to." - tag: Semver! -} - -""" -A published version tag in an OCI repository. - -Each tag corresponds to a single bundle release. Tags are immutable -- once -a version is published, its contents cannot change. Development tags use -a `-dev.TIMESTAMP` suffix (e.g., `1.2.3-dev.20060102T150405Z`). -""" -type OciRepoTag { - "The semantic version string (e.g., `1.2.3` or `1.2.3-dev.20060102T150405Z`)." - tag: Semver! - - "Timestamp when this version was published (UTC)." - createdAt: DateTime! - - """ - The [OCI manifest media type](https://github.com/opencontainers/image-spec/blob/main/manifest.md) - wrapping this tag. Currently always - `application/vnd.oci.image.manifest.v1+json`. The artifact type the - manifest describes is available on `OciRepo.artifactType`. - """ - mediaType: String! - - """ - Content-addressable digest of this tag's manifest, in the form - `sha256:` (for example, `sha256:e3b0c4...`). Combine with - `OciRepo.reference` as `@` to pin a pull to this exact manifest. - """ - digest: String! - - "Size of the manifest blob in bytes." - size: Int! - - """ - The README for this version, in markdown. `null` if the publisher didn't - include one. Each version's README is preserved as it was at publish time. - """ - readme: String - - """ - The changelog for this version, in markdown. `null` if the publisher - didn't include one. Each version's changelog is preserved as it was at - publish time. - """ - changelog: String - - """ - Files packaged into this version of the artifact (e.g., `README.md`, - schemas, source files, templates — whatever the publisher included). - - Each item carries a downloadable `url`, a content-addressable `digest`, - its declared `mediaType`, and its size — enough to render or verify the - file. Defaults to `name` ascending. - """ - files( - "Sort order for files. Defaults to `name` ascending." - sort: OciFilesSort - - "Cursor from a previous page's `cursor.next` or `cursor.previous`." - cursor: Cursor - ): OciFilesPage -} - -""" -A single file inside an OCI artifact — for example, a bundle's -`README.md`, a Terraform module's `src/main.tf`, or a Helm chart's -`templates/deployment.yaml`. File names may include nested paths. - -Use `url` to download the bytes; use `mediaType` to decide how to render -them; use `digest` and `size` to verify integrity or to pull the file -directly with an OCI client. -""" -type OciFile { - "The file's path within the artifact, including any directory segments (e.g., `README.md`, `src\/main.tf`, `helm\/templates\/deployment.yaml`)." - name: String! - - "Content type the publisher declared for this file (e.g., `text\/markdown`, `application\/json`, `image\/svg+xml`, `text\/x-terraform`). Use this to pick a renderer in your UI — the response from `url` is always served as plain text for safety, so the consumer is responsible for interpreting the bytes." - mediaType: String! - - "File size in bytes." - size: Int! - - "Content-addressable SHA-256 digest of the file (e.g., `sha256:e3b0c44298fc1c14...`). Stable across re-publishes of identical content; useful for integrity checks or pulling the file directly with an OCI client like `oras`." - digest: String! - - "Authenticated URL to download this file. Requires an active session or API credentials, and `repo:view` on the OCI repository. The response is always served as `text\/plain` (regardless of the file's `mediaType`) so it cannot execute scripts in a browser. To render the file, fetch the URL with credentials, read the bytes, and render them client-side according to `mediaType`. Direct embedding via `` or `