diff --git a/.golangci.yaml b/.golangci.yaml index 18fdb6bc..4fa6e805 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -397,6 +397,14 @@ linters: # Omit embedded fields from selector expression. # https://staticcheck.dev/docs/checks/#QF1008 - -QF1008 + # The doc-comment-form family: a comment on an exported symbol must + # begin with its name. Off for the same reason as revive's `exported` + # rule above — this module is a binary, not a library, so a comment + # here is free-form prose rather than generated API documentation. + # https://staticcheck.dev/docs/checks/#ST1020 + - -ST1020 + - -ST1021 + - -ST1022 usetesting: # Enable/disable `os.TempDir()` detections. @@ -417,6 +425,10 @@ linters: # Allow unused params at the cobra command level - linters: [revive] text: "unused-parameter: parameter ('cmd'|'args') seems to be unused, consider removing or renaming it as _" + # This module ships a CLI binary, not a library. Nothing here is importable + # API, so doc comments are a judgement call rather than a requirement. + - linters: [revive] + text: "^exported:" - path: "_test\\.go" linters: - revive diff --git a/cmd/bundle.go b/cmd/bundle.go index 5153ca17..492e697f 100644 --- a/cmd/bundle.go +++ b/cmd/bundle.go @@ -167,11 +167,11 @@ func NewCmdBundle() *cobra.Command { //nolint:funlen // cobra command builders a bundleTemplateListCmd.Flags().StringP("output", "o", "text", "Output format (text, json)") bundleCreateCmd := &cobra.Command{ - Use: "create ", - Short: "Create a new bundle OCI repository in your organization's catalog", - Example: `mass bundle create aws-aurora-postgres -a owner=data,service=database`, - Args: cobra.ExactArgs(1), - RunE: runBundleCreate, + Use: "create ", + Short: "Create a new bundle OCI repository in your organization's catalog", + Long: helpdocs.MustRender("bundle/create"), + Args: cobra.ExactArgs(1), + RunE: runBundleCreate, } bundleCreateCmd.Flags().StringToStringP("attributes", "a", nil, "Custom attributes (e.g. -a owner=data,service=database)") diff --git a/cmd/component.go b/cmd/component.go index 0e718eca..74159a1f 100644 --- a/cmd/component.go +++ b/cmd/component.go @@ -24,12 +24,11 @@ func NewCmdComponent() *cobra.Command { } componentAddCmd := &cobra.Command{ - Use: "add ", - Short: "Add a component to a project's blueprint", - Example: `mass component add ecomm aws-rds-cluster --id db --name "Primary Database"`, - Long: helpdocs.MustRender("component/add"), - Args: cobra.ExactArgs(2), - RunE: runComponentAdd, + Use: "add ", + Short: "Add a component to a project's blueprint", + Long: helpdocs.MustRender("component/add"), + Args: cobra.ExactArgs(2), + RunE: runComponentAdd, } componentAddCmd.Flags().String("id", "", "Short identifier for this component (e.g., db). Max 20 chars, lowercase alphanumeric.") componentAddCmd.Flags().StringP("name", "n", "", "Display name (defaults to --id if not provided)") @@ -38,11 +37,11 @@ func NewCmdComponent() *cobra.Command { _ = componentAddCmd.MarkFlagRequired("id") componentUpdateCmd := &cobra.Command{ - Use: "update ", - Short: "Update a component's name, description, or attributes", - Example: `mass component update ecomm-db --name "Primary DB" -a priority=high`, - Args: cobra.ExactArgs(1), - RunE: runComponentUpdate, + Use: "update ", + Short: "Update a component's name, description, or attributes", + Long: helpdocs.MustRender("component/update"), + Args: cobra.ExactArgs(1), + RunE: runComponentUpdate, } componentUpdateCmd.Flags().StringP("name", "n", "", "New display name") componentUpdateCmd.Flags().StringP("description", "d", "", "New description") @@ -52,30 +51,27 @@ func NewCmdComponent() *cobra.Command { Use: "remove ", Aliases: []string{"rm"}, Short: "Remove a component from a project's blueprint", - Example: `mass component remove ecomm-db`, Long: helpdocs.MustRender("component/remove"), Args: cobra.ExactArgs(1), RunE: runComponentRemove, } componentLinkCmd := &cobra.Command{ - Use: "link . .", - Short: "Link two components in a project's blueprint", - Example: `mass component link ecomm-db.authentication ecomm-app.database --from-version ~1.0 --to-version ~2.0`, - Long: helpdocs.MustRender("component/link"), - Args: cobra.ExactArgs(2), - RunE: runComponentLink, + Use: "link . .", + Short: "Link two components in a project's blueprint", + Long: helpdocs.MustRender("component/link"), + Args: cobra.ExactArgs(2), + RunE: runComponentLink, } componentLinkCmd.Flags().String("from-version", "latest", "Version constraint for the source component") componentLinkCmd.Flags().String("to-version", "latest", "Version constraint for the destination component") componentUnlinkCmd := &cobra.Command{ - Use: "unlink . .", - Short: "Remove a link between two components", - Example: `mass component unlink ecomm-db.authentication ecomm-app.database`, - Long: helpdocs.MustRender("component/unlink"), - Args: cobra.ExactArgs(2), - RunE: runComponentUnlink, + Use: "unlink . .", + Short: "Remove a link between two components", + Long: helpdocs.MustRender("component/unlink"), + Args: cobra.ExactArgs(2), + RunE: runComponentUnlink, } componentCmd.AddCommand(componentAddCmd) diff --git a/cmd/deployment.go b/cmd/deployment.go index b67b1e77..45b1f849 100644 --- a/cmd/deployment.go +++ b/cmd/deployment.go @@ -38,12 +38,11 @@ func NewCmdDeployment() *cobra.Command { } deploymentGetCmd := &cobra.Command{ - Use: "get ", - Short: "Get a deployment by ID", - Example: `mass deployment get 12345678-1234-1234-1234-123456789012`, - Long: helpdocs.MustRender("deployment/get"), - Args: cobra.ExactArgs(1), - RunE: runDeploymentGet, + Use: "get ", + Short: "Get a deployment by ID", + Long: helpdocs.MustRender("deployment/get"), + Args: cobra.ExactArgs(1), + RunE: runDeploymentGet, } deploymentGetCmd.Flags().StringP("output", "o", "text", "Output format (text or json)") @@ -51,7 +50,6 @@ func NewCmdDeployment() *cobra.Command { Use: "list ", Aliases: []string{"ls"}, Short: "List deployments for an instance (most recent first)", - Example: `mass deployment list ecomm-prod-db --limit 25`, Long: helpdocs.MustRender("deployment/list"), Args: cobra.ExactArgs(1), RunE: runDeploymentList, @@ -64,47 +62,42 @@ func NewCmdDeployment() *cobra.Command { deploymentListCmd.Flags().String("bundle", "", "Filter by bundle version (name@version) or release channel (name@latest)") deploymentLogsCmd := &cobra.Command{ - Use: "logs ", - Short: "Stream the log output from a deployment", - Example: `mass deployment logs 12345678-1234-1234-1234-123456789012`, - Long: helpdocs.MustRender("deployment/logs"), - Args: cobra.ExactArgs(1), - RunE: runDeploymentLogs, + Use: "logs ", + Short: "Stream the log output from a deployment", + Long: helpdocs.MustRender("deployment/logs"), + Args: cobra.ExactArgs(1), + RunE: runDeploymentLogs, } deploymentAbortCmd := &cobra.Command{ - Use: "abort ", - Short: "Abort a pending, approved, or running deployment", - Example: `mass deployment abort 12345678-1234-1234-1234-123456789012 --force`, - Long: helpdocs.MustRender("deployment/abort"), - Args: cobra.ExactArgs(1), - RunE: runDeploymentAbort, + Use: "abort ", + Short: "Abort a pending, approved, or running deployment", + Long: helpdocs.MustRender("deployment/abort"), + Args: cobra.ExactArgs(1), + RunE: runDeploymentAbort, } deploymentAbortCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") deploymentApproveCmd := &cobra.Command{ - Use: "approve ", - Short: "Approve a proposed deployment, releasing it to run", - Example: `mass deployment approve 12345678-1234-1234-1234-123456789012`, - Long: helpdocs.MustRender("deployment/approve"), - Args: cobra.ExactArgs(1), - RunE: runDeploymentApprove, + Use: "approve ", + Short: "Approve a proposed deployment, releasing it to run", + Long: helpdocs.MustRender("deployment/approve"), + Args: cobra.ExactArgs(1), + RunE: runDeploymentApprove, } deploymentRejectCmd := &cobra.Command{ - Use: "reject ", - Short: "Reject a proposed deployment, discarding it permanently", - Example: `mass deployment reject 12345678-1234-1234-1234-123456789012`, - Long: helpdocs.MustRender("deployment/reject"), - Args: cobra.ExactArgs(1), - RunE: runDeploymentReject, + Use: "reject ", + Short: "Reject a proposed deployment, discarding it permanently", + Long: helpdocs.MustRender("deployment/reject"), + Args: cobra.ExactArgs(1), + RunE: runDeploymentReject, } deploymentCompareCmd := &cobra.Command{ Use: "compare ", Aliases: []string{"diff"}, Short: "Compare two deployments' bundle version and params", - Example: `mass deployment compare 1111... 2222...`, Long: helpdocs.MustRender("deployment/compare"), Args: cobra.ExactArgs(2), RunE: runDeploymentCompare, diff --git a/cmd/environment.go b/cmd/environment.go index ead1397b..e54bc31d 100644 --- a/cmd/environment.go +++ b/cmd/environment.go @@ -110,12 +110,11 @@ func NewCmdEnvironment() *cobra.Command { func newEnvironmentDeleteCmd() *cobra.Command { c := &cobra.Command{ - Use: "delete [environment]", - Short: "Delete an environment", - Example: `mass environment delete ecomm-staging`, - Long: helpdocs.MustRender("environment/delete"), - Args: cobra.ExactArgs(1), - RunE: runEnvironmentDelete, + Use: "delete [environment]", + Short: "Delete an environment", + Long: helpdocs.MustRender("environment/delete"), + Args: cobra.ExactArgs(1), + RunE: runEnvironmentDelete, } c.Flags().BoolP("force", "f", false, "Skip confirmation prompt") return c @@ -126,7 +125,6 @@ func newEnvironmentCompareCmd() *cobra.Command { Use: "compare [source-environment] [target-environment]", Aliases: []string{"diff"}, Short: "Compare two environments instance-by-instance", - Example: `mass environment compare ecomm-staging ecomm-production`, Long: helpdocs.MustRender("environment/compare"), Args: cobra.ExactArgs(2), RunE: runEnvironmentCompare, @@ -154,12 +152,11 @@ func newEnvironmentPreviewCmd() *cobra.Command { func newEnvironmentForkCmd() *cobra.Command { c := &cobra.Command{ - Use: "fork [parent-environment] [new-ID]", - Short: "Fork an existing environment", - Example: `mass environment fork ecomm-production staging`, - Long: helpdocs.MustRender("environment/fork"), - Args: cobra.ExactArgs(2), - RunE: runEnvironmentFork, + Use: "fork [parent-environment] [new-ID]", + Short: "Fork an existing environment", + Long: helpdocs.MustRender("environment/fork"), + Args: cobra.ExactArgs(2), + RunE: runEnvironmentFork, } c.Flags().StringP("name", "n", "", "Environment name (defaults to new-ID if not provided)") c.Flags().StringP("description", "d", "", "Optional environment description") @@ -172,12 +169,11 @@ func newEnvironmentForkCmd() *cobra.Command { func newEnvironmentDeployCmd() *cobra.Command { c := &cobra.Command{ - Use: "deploy [environment]", - Short: "Deploy every instance in an environment, in dependency order", - Example: `mass environment deploy ecomm-staging --follow`, - Long: helpdocs.MustRender("environment/deploy"), - Args: cobra.ExactArgs(1), - RunE: runEnvironmentDeploy, + Use: "deploy [environment]", + Short: "Deploy every instance in an environment, in dependency order", + Long: helpdocs.MustRender("environment/deploy"), + Args: cobra.ExactArgs(1), + RunE: runEnvironmentDeploy, } c.Flags().Bool("follow", false, "Stream every deployment's logs to stdout until the rollout completes. Each line is prefixed with the instance id.") return c @@ -185,12 +181,11 @@ func newEnvironmentDeployCmd() *cobra.Command { func newEnvironmentDecommissionCmd() *cobra.Command { c := &cobra.Command{ - Use: "decommission [environment]", - Short: "Decommission every instance in an environment, in reverse dependency order", - Example: `mass environment decommission ecomm-pr42 --follow`, - Long: helpdocs.MustRender("environment/decommission"), - Args: cobra.ExactArgs(1), - RunE: runEnvironmentDecommission, + Use: "decommission [environment]", + Short: "Decommission every instance in an environment, in reverse dependency order", + Long: helpdocs.MustRender("environment/decommission"), + Args: cobra.ExactArgs(1), + RunE: runEnvironmentDecommission, } c.Flags().Bool("follow", false, "Stream every decommission deployment's logs to stdout until the rollout completes. Each line is prefixed with the instance id.") return c diff --git a/cmd/instance.go b/cmd/instance.go index 4818b418..48fad7dc 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -40,12 +40,11 @@ func NewCmdInstance() *cobra.Command { instanceDeployCmd := newInstanceDeployCmd() instanceExportCmd := &cobra.Command{ - Use: `export --`, - Short: "Export instances", - Example: `mass instance export ecomm-prod-vpc`, - Long: helpdocs.MustRender("instance/export"), - Args: cobra.ExactArgs(1), - RunE: runInstanceExport, + Use: `export --`, + Short: "Export instances", + Long: helpdocs.MustRender("instance/export"), + Args: cobra.ExactArgs(1), + RunE: runInstanceExport, } // instance and infra are the same, lets reuse a get command/template here. @@ -53,7 +52,6 @@ func NewCmdInstance() *cobra.Command { Use: `get --`, Short: "Get an instance", Aliases: []string{"g"}, - Example: `mass instance get ecomm-prod-vpc`, Long: helpdocs.MustRender("instance/get"), Args: cobra.ExactArgs(1), RunE: runInstanceGet, @@ -61,21 +59,19 @@ func NewCmdInstance() *cobra.Command { instanceGetCmd.Flags().StringP("output", "o", "text", "Output format (text or json)") instanceVersionCmd := &cobra.Command{ - Use: `version @`, - Short: "Set instance version", - Example: `mass instance version api-prod-db@latest`, - Long: helpdocs.MustRender("instance/version"), - Args: cobra.ExactArgs(1), - RunE: runInstanceVersion, + Use: `version @`, + Short: "Set instance version", + Long: helpdocs.MustRender("instance/version"), + Args: cobra.ExactArgs(1), + RunE: runInstanceVersion, } instanceDestroyCmd := &cobra.Command{ - Use: `destroy --`, - Short: "Destroy (decommission) an instance", - Example: `mass instance destroy api-prod-db --force`, - Long: "Destroy (decommission) an instance. This will permanently delete the instance and all its resources.", - Args: cobra.ExactArgs(1), - RunE: runInstanceDeploy, + Use: `destroy --`, + Short: "Destroy (decommission) an instance", + Long: helpdocs.MustRender("instance/destroy"), + Args: cobra.ExactArgs(1), + RunE: runInstanceDeploy, } instanceDestroyCmd.Flags().StringP("message", "m", "", "Add a message when decommissioning") instanceDestroyCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") @@ -88,7 +84,6 @@ func NewCmdInstance() *cobra.Command { Use: `list -`, Short: "List instances in an environment", Aliases: []string{"ls"}, - Example: `mass instance list ecomm-prod`, Long: helpdocs.MustRender("instance/list"), Args: cobra.ExactArgs(1), RunE: runInstanceList, @@ -99,12 +94,11 @@ func NewCmdInstance() *cobra.Command { instanceListCmd.Flags().String("bundle", "", "Filter by bundle version (name@version) or release channel (name@latest)") instanceOrphanCmd := &cobra.Command{ - Use: `orphan --`, - Short: "Orphan an instance (reset to INITIALIZED, optionally clearing state locks)", - Example: `mass instance orphan api-prod-db --force`, - Long: helpdocs.MustRender("instance/orphan"), - Args: cobra.ExactArgs(1), - RunE: runInstanceOrphan, + Use: `orphan --`, + Short: "Orphan an instance (reset to INITIALIZED, optionally clearing state locks)", + Long: helpdocs.MustRender("instance/orphan"), + Args: cobra.ExactArgs(1), + RunE: runInstanceOrphan, } instanceOrphanCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") instanceOrphanCmd.Flags().Bool("delete-state", false, "Also delete the remote Terraform/OpenTofu state files (irreversible)") @@ -125,12 +119,11 @@ func NewCmdInstance() *cobra.Command { func newInstanceRollbackCmd() *cobra.Command { return &cobra.Command{ - Use: `rollback `, - Short: "Propose rolling an instance back to a past completed deployment", - Example: `mass instance rollback 12345678-1234-1234-1234-123456789012`, - Long: helpdocs.MustRender("instance/rollback"), - Args: cobra.ExactArgs(1), - RunE: runInstanceRollback, + Use: `rollback `, + Short: "Propose rolling an instance back to a past completed deployment", + Long: helpdocs.MustRender("instance/rollback"), + Args: cobra.ExactArgs(1), + RunE: runInstanceRollback, } } @@ -143,19 +136,17 @@ func newInstanceRemoteReferenceCmd() *cobra.Command { } setCmd := &cobra.Command{ - Use: "set ", - Short: "Override a connection slot with a resource from another project", - Example: `mass instance remote-reference set ecomm-prod-api database ecomm-prod-db.postgres`, - Long: helpdocs.MustRender("instance/remote-reference-set"), - Args: cobra.ExactArgs(3), - RunE: runInstanceRemoteReferenceSet, + Use: "set ", + Short: "Override a connection slot with a resource from another project", + Long: helpdocs.MustRender("instance/remote-reference-set"), + Args: cobra.ExactArgs(3), + RunE: runInstanceRemoteReferenceSet, } removeCmd := &cobra.Command{ Use: "remove ", Aliases: []string{"rm", "unset"}, Short: "Remove a connection slot override, reverting to the blueprint wiring", - Example: `mass instance remote-reference remove ecomm-prod-api database`, Long: helpdocs.MustRender("instance/remote-reference-remove"), Args: cobra.ExactArgs(2), RunE: runInstanceRemoteReferenceRemove, @@ -168,12 +159,11 @@ func newInstanceRemoteReferenceCmd() *cobra.Command { func newInstanceDeployCmd() *cobra.Command { c := &cobra.Command{ - Use: `deploy --`, - Short: "Deploy instances", - Example: `mass instance deploy ecomm-prod-vpc`, - Long: helpdocs.MustRender("instance/deploy"), - Args: cobra.ExactArgs(1), - RunE: runInstanceDeploy, + Use: `deploy --`, + Short: "Deploy instances", + Long: helpdocs.MustRender("instance/deploy"), + Args: cobra.ExactArgs(1), + RunE: runInstanceDeploy, } c.Flags().StringP("message", "m", "", "Add a message when deploying") c.Flags().StringP("params", "p", "", "Path to params json, tfvars or yaml file. Use '-' to read from stdin. When provided, the full configuration is replaced. Supports bash interpolation.") @@ -191,7 +181,6 @@ func newInstanceCopyCmd() *cobra.Command { Use: `copy [source] --to [destination]`, Aliases: []string{"promote"}, Short: "Copy an instance's configuration to another instance of the same component", - Example: `mass instance promote ecomm-staging-db --to ecomm-production-db --copy-secrets`, Long: helpdocs.MustRender("instance/copy"), Args: cobra.ExactArgs(1), RunE: runInstanceCopy, diff --git a/cmd/project.go b/cmd/project.go index 9f87516f..1471fea7 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -93,12 +93,11 @@ func NewCmdProject() *cobra.Command { projectDeleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") projectCloneCmd := &cobra.Command{ - Use: "clone [source-project] [new-id]", - Short: "Clone a project's blueprint into a new project", - Example: `mass project clone ecomm ecomm-copy`, - Long: helpdocs.MustRender("project/clone"), - Args: cobra.ExactArgs(2), - RunE: runProjectClone, + Use: "clone [source-project] [new-id]", + Short: "Clone a project's blueprint into a new project", + Long: helpdocs.MustRender("project/clone"), + Args: cobra.ExactArgs(2), + RunE: runProjectClone, } projectCloneCmd.Flags().StringP("name", "n", "", "New project name (defaults to new-id if not provided)") projectCloneCmd.Flags().StringP("description", "d", "", "Optional project description") diff --git a/cmd/repository.go b/cmd/repository.go index f58a5b25..d77e5036 100644 --- a/cmd/repository.go +++ b/cmd/repository.go @@ -14,7 +14,9 @@ import ( "time" "github.com/charmbracelet/glamour" + "github.com/massdriver-cloud/mass/docs/helpdocs" "github.com/massdriver-cloud/mass/internal/cli" + "github.com/massdriver-cloud/mass/internal/commands/grants" cmdrepository "github.com/massdriver-cloud/mass/internal/commands/repository" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/ocirepos" @@ -91,6 +93,7 @@ func NewCmdRepository() *cobra.Command { repositoryCmd.AddCommand(repositoryCreateCmd) repositoryCmd.AddCommand(repositoryUpdateCmd) repositoryCmd.AddCommand(repositoryDeleteCmd) + repositoryCmd.AddCommand(newRepositoryGrantCmd()) return repositoryCmd } @@ -419,3 +422,132 @@ func runRepositoryDelete(cmd *cobra.Command, args []string) error { fmt.Printf("Repository %s deleted successfully\n", deleted.Name) return nil } + +type repositoryGrantCreateInput struct { + conditions []string + conditionsFile string + allProjects bool + action string +} + +//nolint:dupl // parallel command trees per recipient kind, not redundant logic +func newRepositoryGrantCmd() *cobra.Command { + repositoryGrantCmd := &cobra.Command{ + Use: "grant", + Aliases: []string{"grants"}, + Short: "Manage sharing grants on an OCI repository", + Long: helpdocs.MustRender("repository/grant"), + } + + createInput := repositoryGrantCreateInput{} + repositoryGrantCreateCmd := &cobra.Command{ + Use: "create ", + Short: "Share a repository with recipient projects", + Long: helpdocs.MustRender("repository/grant-create"), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + return runRepositoryGrantCreate(args[0], &createInput) + }, + } + repositoryGrantCreateCmd.Flags().StringArrayVar(&createInput.conditions, "condition", nil, "Recipient project attribute condition; repeat a key to accept a set of values, or write key=* to accept any value") + repositoryGrantCreateCmd.Flags().StringVar(&createInput.conditionsFile, "conditions-file", "", "Read recipient conditions from a JSON file") + repositoryGrantCreateCmd.Flags().BoolVar(&createInput.allProjects, "all-projects", false, "Share with every project in the organization") + repositoryGrantCreateCmd.Flags().StringVar(&createInput.action, "action", "", "Action to grant (default "+grants.ActionRepoPull+")") + + repositoryGrantListCmd := &cobra.Command{ + Use: "list ", + Aliases: []string{"ls"}, + Short: "List the sharing grants on a repository", + Long: helpdocs.MustRender("repository/grant-list"), + Args: cobra.ExactArgs(1), + RunE: runRepositoryGrantList, + } + repositoryGrantListCmd.Flags().StringP("output", "o", "table", "Output format (table, json)") + + repositoryGrantDeleteCmd := &cobra.Command{ + Use: "delete ", + Short: "Revoke a sharing grant by id", + Long: helpdocs.MustRender("repository/grant-delete"), + Args: cobra.ExactArgs(1), + RunE: runRepositoryGrantDelete, + } + + repositoryGrantCmd.AddCommand(repositoryGrantCreateCmd) + repositoryGrantCmd.AddCommand(repositoryGrantListCmd) + repositoryGrantCmd.AddCommand(repositoryGrantDeleteCmd) + + return repositoryGrantCmd +} + +func runRepositoryGrantCreate(name string, input *repositoryGrantCreateInput) error { + ctx := context.Background() + + action, err := grants.ResolveRepoAction(input.action) + if err != nil { + return err + } + conditions, err := grants.ResolveConditions(input.conditions, input.conditionsFile, input.allProjects, "all-projects") + if err != nil { + return err + } + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) + } + + grant, err := mdClient.OciRepos.CreateGrant(ctx, name, ocirepos.CreateGrantInput{ + Action: action, + RecipientConditions: conditions, + }) + if err != nil { + return grants.NotFoundHint(err, "repository", name) + } + + fmt.Printf("✅ Repository `%s` shared as `%s` with %s (grant %s)\n", name, grant.Action, grants.FormatConditions(grant.RecipientConditions), grant.ID) + return nil +} + +func runRepositoryGrantList(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + name := args[0] + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + + cmd.SilenceUsage = true + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) + } + + seq := mdClient.OciRepos.IterGrants(ctx, name, ocirepos.ListGrantsInput{}) + return grants.NotFoundHint(grants.Render(seq, outputFormat, os.Stdout), "repository", name) +} + +func runRepositoryGrantDelete(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + grantID := args[0] + cmd.SilenceUsage = true + + if validateErr := grants.ValidateGrantID(grantID, "mass repository grant list "); validateErr != nil { + return validateErr + } + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) + } + + if deleteErr := mdClient.OciRepos.DeleteGrant(ctx, grantID); deleteErr != nil { + return deleteErr + } + + fmt.Printf("Grant %s deleted successfully\n", grantID) + return nil +} diff --git a/cmd/resource.go b/cmd/resource.go index 4dd64de8..dad23419 100644 --- a/cmd/resource.go +++ b/cmd/resource.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/glamour" "github.com/massdriver-cloud/mass/docs/helpdocs" "github.com/massdriver-cloud/mass/internal/cli" + "github.com/massdriver-cloud/mass/internal/commands/grants" "github.com/massdriver-cloud/mass/internal/commands/resource" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/resources" @@ -37,6 +38,7 @@ func NewCmdResource() *cobra.Command { resourceCmd.AddCommand(newResourceUpdateCmd()) resourceCmd.AddCommand(newResourceDeleteCmd()) resourceCmd.AddCommand(newResourceListCmd()) + resourceCmd.AddCommand(newResourceGrantCmd()) return resourceCmd } @@ -65,12 +67,6 @@ func newResourceGetCmd() *cobra.Command { Long: helpdocs.MustRender("resource/get"), Args: cobra.ExactArgs(1), RunE: runResourceGet, - Example: ` # Get resource using UUID (imported resources) - mass resource get 12345678-1234-1234-1234-123456789012 - - # Get resource using friendly slug (provisioned resources) - mass resource get api-prod-database-connection - mass resource get api-prod-grpcapi-host -o json`, } resourceGetCmd.Flags().StringP("output", "o", "text", "Output format (text or json)") return resourceGetCmd @@ -83,12 +79,6 @@ func newResourceDownloadCmd() *cobra.Command { Long: helpdocs.MustRender("resource/download"), Args: cobra.ExactArgs(1), RunE: runResourceDownload, - Example: ` # Download resource using UUID (imported resources) - mass resource download 12345678-1234-1234-1234-123456789012 - - # Download resource using friendly slug (provisioned resources) - mass resource download api-prod-database-connection - mass resource download network-useast1-vpc-network -f yaml`, } resourceDownloadCmd.Flags().StringP("format", "f", "json", "Download format (json, yaml, etc.)") return resourceDownloadCmd @@ -101,11 +91,6 @@ func newResourceUpdateCmd() *cobra.Command { Long: helpdocs.MustRender("resource/update"), Args: cobra.ExactArgs(1), RunE: runResourceUpdate, - Example: ` # Update resource payload - mass resource update 12345678-1234-1234-1234-123456789012 -f resource.json - - # Update resource payload and rename - mass resource update 12345678-1234-1234-1234-123456789012 -f resource.json -n new-name`, } resourceUpdateCmd.Flags().StringP("name", "n", "", "New resource name") resourceUpdateCmd.Flags().StringP("file", "f", "", "Resource payload file") @@ -117,13 +102,9 @@ func newResourceDeleteCmd() *cobra.Command { resourceDeleteCmd := &cobra.Command{ Use: "delete [resource-id]", Short: "Delete a resource", + Long: helpdocs.MustRender("resource/delete"), 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") return resourceDeleteCmd @@ -136,13 +117,6 @@ func newResourceListCmd() *cobra.Command { Aliases: []string{"ls"}, Long: helpdocs.MustRender("resource/list"), RunE: runResourceList, - Example: ` # List all resources - mass resource list - - # Search and filter - mass resource list --search database - mass resource list --type aws-iam-role --origin provisioned - mass resource list --environment ecomm-prod -o json`, } resourceListCmd.Flags().StringP("output", "o", "table", "Output format (table, json)") resourceListCmd.Flags().StringP("search", "s", "", "Full-text search across resource name") @@ -452,3 +426,132 @@ func renderResource(res *types.Resource) error { fmt.Print(out) return nil } + +type resourceGrantCreateInput struct { + conditions []string + conditionsFile string + allEnvironments bool + action string +} + +//nolint:dupl // parallel command trees per recipient kind, not redundant logic +func newResourceGrantCmd() *cobra.Command { + resourceGrantCmd := &cobra.Command{ + Use: "grant", + Aliases: []string{"grants"}, + Short: "Manage sharing grants on a resource", + Long: helpdocs.MustRender("resource/grant"), + } + + createInput := resourceGrantCreateInput{} + resourceGrantCreateCmd := &cobra.Command{ + Use: "create ", + Short: "Share a resource with recipient environments", + Long: helpdocs.MustRender("resource/grant-create"), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + return runResourceGrantCreate(args[0], &createInput) + }, + } + resourceGrantCreateCmd.Flags().StringArrayVar(&createInput.conditions, "condition", nil, "Recipient environment attribute condition; repeat a key to accept a set of values, or write key=* to accept any value") + resourceGrantCreateCmd.Flags().StringVar(&createInput.conditionsFile, "conditions-file", "", "Read recipient conditions from a JSON file") + resourceGrantCreateCmd.Flags().BoolVar(&createInput.allEnvironments, "all-environments", false, "Share with every environment in the organization") + resourceGrantCreateCmd.Flags().StringVar(&createInput.action, "action", "", "Action to grant (default "+grants.ActionResourceExport+")") + + resourceGrantListCmd := &cobra.Command{ + Use: "list ", + Aliases: []string{"ls"}, + Short: "List the sharing grants on a resource", + Long: helpdocs.MustRender("resource/grant-list"), + Args: cobra.ExactArgs(1), + RunE: runResourceGrantList, + } + resourceGrantListCmd.Flags().StringP("output", "o", "table", "Output format (table, json)") + + resourceGrantDeleteCmd := &cobra.Command{ + Use: "delete ", + Short: "Revoke a sharing grant by id", + Long: helpdocs.MustRender("resource/grant-delete"), + Args: cobra.ExactArgs(1), + RunE: runResourceGrantDelete, + } + + resourceGrantCmd.AddCommand(resourceGrantCreateCmd) + resourceGrantCmd.AddCommand(resourceGrantListCmd) + resourceGrantCmd.AddCommand(resourceGrantDeleteCmd) + + return resourceGrantCmd +} + +func runResourceGrantCreate(resourceID string, input *resourceGrantCreateInput) error { + ctx := context.Background() + + action, err := grants.ResolveResourceAction(input.action) + if err != nil { + return err + } + conditions, err := grants.ResolveConditions(input.conditions, input.conditionsFile, input.allEnvironments, "all-environments") + if err != nil { + return err + } + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) + } + + grant, err := mdClient.Resources.CreateGrant(ctx, resourceID, resources.CreateGrantInput{ + Action: action, + RecipientConditions: conditions, + }) + if err != nil { + return grants.NotFoundHint(err, "resource", resourceID) + } + + fmt.Printf("✅ Resource `%s` shared as `%s` with %s (grant %s)\n", resourceID, grant.Action, grants.FormatConditions(grant.RecipientConditions), grant.ID) + return nil +} + +func runResourceGrantList(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + resourceID := args[0] + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + + cmd.SilenceUsage = true + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) + } + + seq := mdClient.Resources.IterGrants(ctx, resourceID, resources.ListGrantsInput{}) + return grants.NotFoundHint(grants.Render(seq, outputFormat, os.Stdout), "resource", resourceID) +} + +func runResourceGrantDelete(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + grantID := args[0] + cmd.SilenceUsage = true + + if validateErr := grants.ValidateGrantID(grantID, "mass resource grant list "); validateErr != nil { + return validateErr + } + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) + } + + if deleteErr := mdClient.Resources.DeleteGrant(ctx, grantID); deleteErr != nil { + return deleteErr + } + + fmt.Printf("Grant %s deleted successfully\n", grantID) + return nil +} diff --git a/cmd/resource_type.go b/cmd/resource_type.go index b4be11d6..27128f4d 100644 --- a/cmd/resource_type.go +++ b/cmd/resource_type.go @@ -38,12 +38,11 @@ func NewCmdType() *cobra.Command { } typeCreateCmd := &cobra.Command{ - Use: "create ", - Short: "Create a new resource type OCI repository in your organization's catalog", - Long: helpdocs.MustRender("type/create"), - Example: `mass resource-type create my-resource-type -a owner=data,service=database`, - Args: cobra.ExactArgs(1), - RunE: runTypeCreate, + Use: "create ", + Short: "Create a new resource type OCI repository in your organization's catalog", + Long: helpdocs.MustRender("type/create"), + Args: cobra.ExactArgs(1), + RunE: runTypeCreate, } typeCreateCmd.Flags().StringToStringP("attributes", "a", nil, "Custom attributes (e.g. -a owner=data,service=database)") @@ -71,7 +70,6 @@ func NewCmdType() *cobra.Command { Aliases: []string{"push"}, Short: "Publish a resource type to Massdriver", Long: helpdocs.MustRender("type/publish"), - Example: `mass resource-type publish ./my-resource-type`, Args: cobra.MaximumNArgs(1), RunE: runTypePublish, } diff --git a/docs/generated/mass_bundle_create.md b/docs/generated/mass_bundle_create.md index 0114c4d9..4c9019e0 100644 --- a/docs/generated/mass_bundle_create.md +++ b/docs/generated/mass_bundle_create.md @@ -8,16 +8,42 @@ sidebar_label: Mass Bundle Create Create a new bundle OCI repository in your organization's catalog -``` +### Synopsis + +# Create Bundle Repository + +Creates an empty bundle OCI repository in your organization's catalog. The repository holds published versions of a bundle; create it before the first `mass bundle publish`. + +## Usage + +```bash mass bundle create [flags] ``` -### Examples +## Flags -``` +- `-a, --attributes` — custom attributes for ABAC (repeat or comma-separate) + +## Examples + +```bash +# Create a repository for an Aurora Postgres bundle +mass bundle create aws-aurora-postgres + +# Tag it with attributes policies and grants can match on mass bundle create aws-aurora-postgres -a owner=data,service=database ``` +## Notes + +- This is `mass repository create --type bundle` with the type filled in. +- Share the repository with other projects using `mass repository grant create`. + + +``` +mass bundle create [flags] +``` + ### Options ``` diff --git a/docs/generated/mass_component_add.md b/docs/generated/mass_component_add.md index c6c422d2..691c3905 100644 --- a/docs/generated/mass_component_add.md +++ b/docs/generated/mass_component_add.md @@ -39,12 +39,6 @@ mass component add ecomm aws-rds-cluster --id db \ mass component add [flags] ``` -### Examples - -``` -mass component add ecomm aws-rds-cluster --id db --name "Primary Database" -``` - ### Options ``` diff --git a/docs/generated/mass_component_link.md b/docs/generated/mass_component_link.md index be703da6..0340d38c 100644 --- a/docs/generated/mass_component_link.md +++ b/docs/generated/mass_component_link.md @@ -41,12 +41,6 @@ mass component link ecomm-db.authentication ecomm-app.database \ mass component link . . [flags] ``` -### Examples - -``` -mass component link ecomm-db.authentication ecomm-app.database --from-version ~1.0 --to-version ~2.0 -``` - ### Options ``` diff --git a/docs/generated/mass_component_remove.md b/docs/generated/mass_component_remove.md index 8b2765f5..eb4fb89e 100644 --- a/docs/generated/mass_component_remove.md +++ b/docs/generated/mass_component_remove.md @@ -33,12 +33,6 @@ mass component remove ecomm-db mass component remove [flags] ``` -### Examples - -``` -mass component remove ecomm-db -``` - ### Options ``` diff --git a/docs/generated/mass_component_unlink.md b/docs/generated/mass_component_unlink.md index 7f82c2a0..99f035ae 100644 --- a/docs/generated/mass_component_unlink.md +++ b/docs/generated/mass_component_unlink.md @@ -31,12 +31,6 @@ mass component unlink ecomm-db.authentication ecomm-app.database mass component unlink . . [flags] ``` -### Examples - -``` -mass component unlink ecomm-db.authentication ecomm-app.database -``` - ### Options ``` diff --git a/docs/generated/mass_component_update.md b/docs/generated/mass_component_update.md index e6aad768..e119bae5 100644 --- a/docs/generated/mass_component_update.md +++ b/docs/generated/mass_component_update.md @@ -8,14 +8,42 @@ sidebar_label: Mass Component Update Update a component's name, description, or attributes -``` +### Synopsis + +# Update Component + +Updates a component's display name, description, or custom attributes. + +Attributes are replaced wholesale rather than merged, so pass every attribute you want to keep. + +## Usage + +```bash mass component update [flags] ``` -### Examples +## Flags -``` +- `-n, --name` — new display name +- `-d, --description` — new description +- `-a, --attributes` — replacement custom attributes (repeat or comma-separate) + +## Examples + +```bash +# Rename a component +mass component update ecomm-db --name "Primary DB" + +# Rename and retag it in one call mass component update ecomm-db --name "Primary DB" -a priority=high + +# Replace the full attribute set +mass component update ecomm-db -a priority=high,cost-center=engineering +``` + + +``` +mass component update [flags] ``` ### Options diff --git a/docs/generated/mass_deployment_abort.md b/docs/generated/mass_deployment_abort.md index b9bdf6b7..53f806de 100644 --- a/docs/generated/mass_deployment_abort.md +++ b/docs/generated/mass_deployment_abort.md @@ -44,12 +44,6 @@ mass deployment abort 12345678-1234-1234-1234-123456789012 --force mass deployment abort [flags] ``` -### Examples - -``` -mass deployment abort 12345678-1234-1234-1234-123456789012 --force -``` - ### Options ``` diff --git a/docs/generated/mass_deployment_approve.md b/docs/generated/mass_deployment_approve.md index 07764f7e..93bd0904 100644 --- a/docs/generated/mass_deployment_approve.md +++ b/docs/generated/mass_deployment_approve.md @@ -33,12 +33,6 @@ mass deployment approve 12345678-1234-1234-1234-123456789012 mass deployment approve [flags] ``` -### Examples - -``` -mass deployment approve 12345678-1234-1234-1234-123456789012 -``` - ### Options ``` diff --git a/docs/generated/mass_deployment_compare.md b/docs/generated/mass_deployment_compare.md index 443846c8..006b611f 100644 --- a/docs/generated/mass_deployment_compare.md +++ b/docs/generated/mass_deployment_compare.md @@ -47,12 +47,6 @@ mass deployment compare 1111... 2222... -o json mass deployment compare [flags] ``` -### Examples - -``` -mass deployment compare 1111... 2222... -``` - ### Options ``` diff --git a/docs/generated/mass_deployment_get.md b/docs/generated/mass_deployment_get.md index 3ec7fa41..0876d9e1 100644 --- a/docs/generated/mass_deployment_get.md +++ b/docs/generated/mass_deployment_get.md @@ -32,12 +32,6 @@ mass deployment get 12345678-1234-1234-1234-123456789012 --output json mass deployment get [flags] ``` -### Examples - -``` -mass deployment get 12345678-1234-1234-1234-123456789012 -``` - ### Options ``` diff --git a/docs/generated/mass_deployment_list.md b/docs/generated/mass_deployment_list.md index e36c6f44..c4e812a2 100644 --- a/docs/generated/mass_deployment_list.md +++ b/docs/generated/mass_deployment_list.md @@ -35,12 +35,6 @@ mass deployment list ecomm-prod-db --limit 50 mass deployment list [flags] ``` -### Examples - -``` -mass deployment list ecomm-prod-db --limit 25 -``` - ### Options ``` diff --git a/docs/generated/mass_deployment_logs.md b/docs/generated/mass_deployment_logs.md index 2649203c..f14b9017 100644 --- a/docs/generated/mass_deployment_logs.md +++ b/docs/generated/mass_deployment_logs.md @@ -31,12 +31,6 @@ mass deployment logs 12345678-1234-1234-1234-123456789012 mass deployment logs [flags] ``` -### Examples - -``` -mass deployment logs 12345678-1234-1234-1234-123456789012 -``` - ### Options ``` diff --git a/docs/generated/mass_deployment_reject.md b/docs/generated/mass_deployment_reject.md index 4783b70c..ba6eed0b 100644 --- a/docs/generated/mass_deployment_reject.md +++ b/docs/generated/mass_deployment_reject.md @@ -33,12 +33,6 @@ mass deployment reject 12345678-1234-1234-1234-123456789012 mass deployment reject [flags] ``` -### Examples - -``` -mass deployment reject 12345678-1234-1234-1234-123456789012 -``` - ### Options ``` diff --git a/docs/generated/mass_environment_compare.md b/docs/generated/mass_environment_compare.md index 1546af64..740153ff 100644 --- a/docs/generated/mass_environment_compare.md +++ b/docs/generated/mass_environment_compare.md @@ -47,12 +47,6 @@ mass environment compare ecomm-staging ecomm-production -o json mass environment compare [source-environment] [target-environment] [flags] ``` -### Examples - -``` -mass environment compare ecomm-staging ecomm-production -``` - ### Options ``` diff --git a/docs/generated/mass_environment_decommission.md b/docs/generated/mass_environment_decommission.md index 642e85ad..69338b0e 100644 --- a/docs/generated/mass_environment_decommission.md +++ b/docs/generated/mass_environment_decommission.md @@ -62,12 +62,6 @@ mass environment delete ecomm-pr42 mass environment decommission [environment] [flags] ``` -### Examples - -``` -mass environment decommission ecomm-pr42 --follow -``` - ### Options ``` diff --git a/docs/generated/mass_environment_delete.md b/docs/generated/mass_environment_delete.md index df82155b..b9845ad2 100644 --- a/docs/generated/mass_environment_delete.md +++ b/docs/generated/mass_environment_delete.md @@ -47,12 +47,6 @@ mass environment delete ecomm-staging --force mass environment delete [environment] [flags] ``` -### Examples - -``` -mass environment delete ecomm-staging -``` - ### Options ``` diff --git a/docs/generated/mass_environment_deploy.md b/docs/generated/mass_environment_deploy.md index fa136c6a..a6d567d6 100644 --- a/docs/generated/mass_environment_deploy.md +++ b/docs/generated/mass_environment_deploy.md @@ -45,17 +45,14 @@ mass environment deploy ecomm-staging # Deploy a freshly-forked preview env. mass environment fork ecomm-production pr42 --copy-environment-defaults mass environment deploy ecomm-pr42 -``` - -``` -mass environment deploy [environment] [flags] +# Stream every instance's logs until the rollout finishes. +mass environment deploy ecomm-staging --follow ``` -### Examples ``` -mass environment deploy ecomm-staging --follow +mass environment deploy [environment] [flags] ``` ### Options diff --git a/docs/generated/mass_environment_fork.md b/docs/generated/mass_environment_fork.md index 5ddc2f45..9444067c 100644 --- a/docs/generated/mass_environment_fork.md +++ b/docs/generated/mass_environment_fork.md @@ -65,12 +65,6 @@ mass environment fork ecomm-production staging --copy-environment-defaults mass environment fork [parent-environment] [new-ID] [flags] ``` -### Examples - -``` -mass environment fork ecomm-production staging -``` - ### Options ``` diff --git a/docs/generated/mass_instance_copy.md b/docs/generated/mass_instance_copy.md index 261a2cd6..a4ae2894 100644 --- a/docs/generated/mass_instance_copy.md +++ b/docs/generated/mass_instance_copy.md @@ -67,12 +67,6 @@ mass instance copy ecomm-staging-db \ mass instance copy [source] --to [destination] [flags] ``` -### Examples - -``` -mass instance promote ecomm-staging-db --to ecomm-production-db --copy-secrets -``` - ### Options ``` diff --git a/docs/generated/mass_instance_deploy.md b/docs/generated/mass_instance_deploy.md index a65203f5..37032743 100644 --- a/docs/generated/mass_instance_deploy.md +++ b/docs/generated/mass_instance_deploy.md @@ -74,12 +74,6 @@ mass instance deploy ecomm-prod-db --propose --message "bump db to 13.4" --patch mass instance deploy -- [flags] ``` -### Examples - -``` -mass instance deploy ecomm-prod-vpc -``` - ### Options ``` diff --git a/docs/generated/mass_instance_destroy.md b/docs/generated/mass_instance_destroy.md index 5fce4224..6f70597f 100644 --- a/docs/generated/mass_instance_destroy.md +++ b/docs/generated/mass_instance_destroy.md @@ -10,16 +10,40 @@ Destroy (decommission) an instance ### Synopsis -Destroy (decommission) an instance. This will permanently delete the instance and all its resources. +# Destroy Instance -``` +Destroys (decommissions) an instance. This permanently deletes the instance and all the resources it provisioned. + +## Usage + +```bash mass instance destroy -- [flags] ``` -### Examples +## Flags -``` +- `-m, --message` — add a message when decommissioning +- `-f, --force` — skip the confirmation prompt +- `-p, --params` — path to a params json, tfvars or yaml file; `-` reads stdin +- `-P, --patch` — patch the last deployed configuration with a JQ expression (repeatable) +- `--follow` — stream the deployment's logs to stdout until it completes + +## Examples + +```bash +# Destroy an instance, confirming at the prompt +mass instance destroy api-prod-db + +# Skip the prompt, for scripted teardown mass instance destroy api-prod-db --force + +# Destroy and watch the logs until it finishes +mass instance destroy api-prod-db --force --follow +``` + + +``` +mass instance destroy -- [flags] ``` ### Options diff --git a/docs/generated/mass_instance_export.md b/docs/generated/mass_instance_export.md index e17f231c..74a2d166 100644 --- a/docs/generated/mass_instance_export.md +++ b/docs/generated/mass_instance_export.md @@ -51,12 +51,6 @@ mass instance export web-prod-app mass instance export -- [flags] ``` -### Examples - -``` -mass instance export ecomm-prod-vpc -``` - ### Options ``` diff --git a/docs/generated/mass_instance_get.md b/docs/generated/mass_instance_get.md index 61696c53..c2218274 100644 --- a/docs/generated/mass_instance_get.md +++ b/docs/generated/mass_instance_get.md @@ -36,12 +36,6 @@ The instance slug can be found by hovering over the bundle in the Massdriver dia mass instance get -- [flags] ``` -### Examples - -``` -mass instance get ecomm-prod-vpc -``` - ### Options ``` diff --git a/docs/generated/mass_instance_list.md b/docs/generated/mass_instance_list.md index aaacb929..cfe84c63 100644 --- a/docs/generated/mass_instance_list.md +++ b/docs/generated/mass_instance_list.md @@ -32,12 +32,6 @@ mass instance list ecomm-prod mass instance list - [flags] ``` -### Examples - -``` -mass instance list ecomm-prod -``` - ### Options ``` diff --git a/docs/generated/mass_instance_orphan.md b/docs/generated/mass_instance_orphan.md index 4275e16c..55a3a908 100644 --- a/docs/generated/mass_instance_orphan.md +++ b/docs/generated/mass_instance_orphan.md @@ -41,12 +41,6 @@ mass instance orphan api-prod-db --delete-state mass instance orphan -- [flags] ``` -### Examples - -``` -mass instance orphan api-prod-db --force -``` - ### Options ``` diff --git a/docs/generated/mass_instance_remote-reference_remove.md b/docs/generated/mass_instance_remote-reference_remove.md index 6b886034..4ab7ecf1 100644 --- a/docs/generated/mass_instance_remote-reference_remove.md +++ b/docs/generated/mass_instance_remote-reference_remove.md @@ -35,12 +35,6 @@ mass instance remote-reference remove ecomm-prod-api database mass instance remote-reference remove [flags] ``` -### Examples - -``` -mass instance remote-reference remove ecomm-prod-api database -``` - ### Options ``` diff --git a/docs/generated/mass_instance_remote-reference_set.md b/docs/generated/mass_instance_remote-reference_set.md index bda3166a..7c6b4b8e 100644 --- a/docs/generated/mass_instance_remote-reference_set.md +++ b/docs/generated/mass_instance_remote-reference_set.md @@ -42,12 +42,6 @@ mass instance remote-reference set ecomm-prod-api database 12345678-1234-1234-12 mass instance remote-reference set [flags] ``` -### Examples - -``` -mass instance remote-reference set ecomm-prod-api database ecomm-prod-db.postgres -``` - ### Options ``` diff --git a/docs/generated/mass_instance_rollback.md b/docs/generated/mass_instance_rollback.md index aef85d3a..6b751acb 100644 --- a/docs/generated/mass_instance_rollback.md +++ b/docs/generated/mass_instance_rollback.md @@ -39,12 +39,6 @@ mass deployment approve mass instance rollback [flags] ``` -### Examples - -``` -mass instance rollback 12345678-1234-1234-1234-123456789012 -``` - ### Options ``` diff --git a/docs/generated/mass_instance_version.md b/docs/generated/mass_instance_version.md index 63cef664..4976576d 100644 --- a/docs/generated/mass_instance_version.md +++ b/docs/generated/mass_instance_version.md @@ -36,12 +36,6 @@ The version can be: mass instance version @ [flags] ``` -### Examples - -``` -mass instance version api-prod-db@latest -``` - ### Options ``` diff --git a/docs/generated/mass_project_clone.md b/docs/generated/mass_project_clone.md index 5b11121b..2a551bf3 100644 --- a/docs/generated/mass_project_clone.md +++ b/docs/generated/mass_project_clone.md @@ -46,12 +46,6 @@ mass project clone ecomm ecomm-eu -n "Ecomm (EU)" -a region=eu mass project clone [source-project] [new-id] [flags] ``` -### Examples - -``` -mass project clone ecomm ecomm-copy -``` - ### Options ``` diff --git a/docs/generated/mass_repository.md b/docs/generated/mass_repository.md index ea71c6dc..3557a04b 100644 --- a/docs/generated/mass_repository.md +++ b/docs/generated/mass_repository.md @@ -20,5 +20,6 @@ Manage OCI repositories (bundles and resource types) * [mass repository create](/cli/commands/mass_repository_create) - Create a new OCI repository * [mass repository delete](/cli/commands/mass_repository_delete) - Delete an OCI repository * [mass repository get](/cli/commands/mass_repository_get) - Get an OCI repository by name +* [mass repository grant](/cli/commands/mass_repository_grant) - Manage sharing grants on an OCI repository * [mass repository list](/cli/commands/mass_repository_list) - List OCI repositories * [mass repository update](/cli/commands/mass_repository_update) - Update an OCI repository's attributes diff --git a/docs/generated/mass_repository_grant.md b/docs/generated/mass_repository_grant.md new file mode 100644 index 00000000..50e6634d --- /dev/null +++ b/docs/generated/mass_repository_grant.md @@ -0,0 +1,46 @@ +--- +id: mass_repository_grant.md +slug: /cli/commands/mass_repository_grant +title: Mass Repository Grant +sidebar_label: Mass Repository Grant +--- +## mass repository grant + +Manage sharing grants on an OCI repository + +### Synopsis + +# Repository Grants + +Manages the sharing grants on an OCI repository. + +A grant says "this repository is shared as ``, to recipient projects matching ``." Without a grant, a repository is visible only where it was published. + +Grants are immutable. To change an action or its conditions, delete the grant and create a replacement. + +## Usage + +```bash +mass repository grant create [flags] +mass repository grant list [flags] +mass repository grant delete +``` + +## Notes + +- Creating or deleting a grant requires the `repo:grant` action on the repository. +- Recipients are matched on **project** attributes. Resource grants match on environment attributes instead — see `mass resource grant`. + + +### Options + +``` + -h, --help help for grant +``` + +### SEE ALSO + +* [mass repository](/cli/commands/mass_repository) - Manage OCI repositories (bundles and resource types) +* [mass repository grant create](/cli/commands/mass_repository_grant_create) - Share a repository with recipient projects +* [mass repository grant delete](/cli/commands/mass_repository_grant_delete) - Revoke a sharing grant by id +* [mass repository grant list](/cli/commands/mass_repository_grant_list) - List the sharing grants on a repository diff --git a/docs/generated/mass_repository_grant_create.md b/docs/generated/mass_repository_grant_create.md new file mode 100644 index 00000000..5f4f3d85 --- /dev/null +++ b/docs/generated/mass_repository_grant_create.md @@ -0,0 +1,88 @@ +--- +id: mass_repository_grant_create.md +slug: /cli/commands/mass_repository_grant_create +title: Mass Repository Grant Create +sidebar_label: Mass Repository Grant Create +--- +## mass repository grant create + +Share a repository with recipient projects + +### Synopsis + +# Create Repository Grant + +Shares an OCI repository with recipient projects, so they can pull it. + +Recipients are chosen by matching **project** attributes. You must say who the recipients are: either name conditions with `--condition` / `--conditions-file`, or share with the whole organization with `--all-projects`. Leaving it unstated is an error rather than a silent org-wide grant. + +## Usage + +```bash +mass repository grant create [flags] +``` + +## Flags + +- `--condition` — a recipient project attribute condition, `key=value`. Repeat the same key to accept a set of values; write `key=*` to accept any value as long as the attribute is set. +- `--conditions-file` — read conditions from a JSON file instead +- `--all-projects` — share with every project in the organization +- `--action` — the action to grant. Defaults to `repo:pull`, currently the only grantable repository action. + +`--condition`, `--conditions-file`, and `--all-projects` are mutually exclusive. + +## Examples + +```bash +# Share with projects whose team attribute is platform or data +mass repository grant create aws-aurora-postgres \ + --condition team=platform \ + --condition team=data + +# Share with projects that have any region attribute set +mass repository grant create aws-aurora-postgres --condition 'region=*' + +# Share with every project in the organization +mass repository grant create aws-aurora-postgres --all-projects + +# Read conditions from a JSON file +mass repository grant create aws-aurora-postgres --conditions-file ./conditions.json +``` + +## Conditions file format + +One JSON object, shaped like a grant's `recipientConditions` in `mass repository grant list -o json`. Values are either `"*"` for the per-key wildcard or an array of accepted values: + +```json +{ + "team": ["platform", "data"], + "region": "*" +} +``` + +A file holding `null` or `{}` is rejected — use `--all-projects` to share organization-wide, so the broadest grant is always explicit. + +## Notes + +- Quote `key=*` so your shell does not expand the `*`. +- A comma is an ordinary character in a condition value. Unlike `--attributes`, it does not separate pairs. +- Grants are immutable; to change one, delete it and create a replacement. + + +``` +mass repository grant create [flags] +``` + +### Options + +``` + --action string Action to grant (default repo:pull) + --all-projects Share with every project in the organization + --condition stringArray Recipient project attribute condition; repeat a key to accept a set of values, or write key=* to accept any value + --conditions-file string Read recipient conditions from a JSON file + -h, --help help for create +``` + +### SEE ALSO + +* [mass repository grant](/cli/commands/mass_repository_grant) - Manage sharing grants on an OCI repository diff --git a/docs/generated/mass_repository_grant_delete.md b/docs/generated/mass_repository_grant_delete.md new file mode 100644 index 00000000..502ce0e2 --- /dev/null +++ b/docs/generated/mass_repository_grant_delete.md @@ -0,0 +1,49 @@ +--- +id: mass_repository_grant_delete.md +slug: /cli/commands/mass_repository_grant_delete +title: Mass Repository Grant Delete +sidebar_label: Mass Repository Grant Delete +--- +## mass repository grant delete + +Revoke a sharing grant by id + +### Synopsis + +# Delete Repository Grant + +Revokes a sharing grant by id. Recipients that qualified only through this grant immediately lose access to the repository. + +## Usage + +```bash +mass repository grant delete +``` + +## Examples + +```bash +# Find the grant id, then revoke it +mass repository grant list aws-aurora-postgres +mass repository grant delete 4f2a9c18-1f0e-4a5c-9a3e-2b6d7c8e9f10 +``` + +## Notes + +- Grants are immutable, so changing one means deleting it and creating a replacement. +- This does not prompt for confirmation. A revoked grant can be re-created with `mass repository grant create`. + + +``` +mass repository grant delete [flags] +``` + +### Options + +``` + -h, --help help for delete +``` + +### SEE ALSO + +* [mass repository grant](/cli/commands/mass_repository_grant) - Manage sharing grants on an OCI repository diff --git a/docs/generated/mass_repository_grant_list.md b/docs/generated/mass_repository_grant_list.md new file mode 100644 index 00000000..4d5d85e2 --- /dev/null +++ b/docs/generated/mass_repository_grant_list.md @@ -0,0 +1,58 @@ +--- +id: mass_repository_grant_list.md +slug: /cli/commands/mass_repository_grant_list +title: Mass Repository Grant List +sidebar_label: Mass Repository Grant List +--- +## mass repository grant list + +List the sharing grants on a repository + +### Synopsis + +# List Repository Grants + +Lists the sharing grants authored on an OCI repository — what it is shared as, and which recipient projects qualify. + +If you can see a repository you can see all of its grants; they are publisher-side metadata rather than being visibility-gated themselves. + +## Usage + +```bash +mass repository grant list [flags] +``` + +## Flags + +- `-o, --output` — output format: `table` or `json` + +## Examples + +```bash +# List the grants on a repository +mass repository grant list aws-aurora-postgres + +# As JSON, for scripting +mass repository grant list aws-aurora-postgres -o json +``` + +## Notes + +- The `Recipients` column reads `everyone` for an organization-wide grant, `key=*` for a per-key wildcard, and `key=a|b` for a closed set. +- The `ID` column is what `mass repository grant delete` takes. + + +``` +mass repository grant list [flags] +``` + +### Options + +``` + -h, --help help for list + -o, --output string Output format (table, json) (default "table") +``` + +### SEE ALSO + +* [mass repository grant](/cli/commands/mass_repository_grant) - Manage sharing grants on an OCI repository diff --git a/docs/generated/mass_resource-type_create.md b/docs/generated/mass_resource-type_create.md index 55b16fb2..021d404e 100644 --- a/docs/generated/mass_resource-type_create.md +++ b/docs/generated/mass_resource-type_create.md @@ -37,12 +37,6 @@ mass resource-type create my-resource-type -a owner=data,service=database mass resource-type create [flags] ``` -### Examples - -``` -mass resource-type create my-resource-type -a owner=data,service=database -``` - ### Options ``` diff --git a/docs/generated/mass_resource-type_publish.md b/docs/generated/mass_resource-type_publish.md index 7ed10fe0..0474eafe 100644 --- a/docs/generated/mass_resource-type_publish.md +++ b/docs/generated/mass_resource-type_publish.md @@ -67,12 +67,6 @@ mass resource-type convert ./my-resource-type.json mass resource-type publish [path] [flags] ``` -### Examples - -``` -mass resource-type publish ./my-resource-type -``` - ### Options ``` diff --git a/docs/generated/mass_resource.md b/docs/generated/mass_resource.md index df56c0b6..7caafdd1 100644 --- a/docs/generated/mass_resource.md +++ b/docs/generated/mass_resource.md @@ -28,5 +28,6 @@ Resources represent infrastructure outputs and connections in Massdriver. They c * [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 grant](/cli/commands/mass_resource_grant) - Manage sharing grants on a resource * [mass resource list](/cli/commands/mass_resource_list) - List resources * [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 index b32600d6..68473d1c 100644 --- a/docs/generated/mass_resource_delete.md +++ b/docs/generated/mass_resource_delete.md @@ -8,18 +8,37 @@ sidebar_label: Mass Resource Delete Delete a resource -``` -mass resource delete [resource-id] [flags] +### Synopsis + +# Delete Resource + +Deletes an imported resource from your organization. + +Only imported resources can be deleted this way. Provisioned resources belong to the deployment that created them — remove those with `mass instance destroy`. + +## Usage + +```bash +mass resource delete [flags] ``` -### Examples +## Flags +- `-f, --force` — skip the confirmation prompt + +## Examples + +```bash +# Delete an imported resource, confirming at the prompt +mass resource delete 12345678-1234-1234-1234-123456789012 + +# Skip the confirmation prompt +mass resource delete 12345678-1234-1234-1234-123456789012 --force ``` - # 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 + +``` +mass resource delete [resource-id] [flags] ``` ### Options diff --git a/docs/generated/mass_resource_download.md b/docs/generated/mass_resource_download.md index 98109c61..8be6e393 100644 --- a/docs/generated/mass_resource_download.md +++ b/docs/generated/mass_resource_download.md @@ -60,17 +60,6 @@ mass resource download 12345678-1234-1234-1234-123456789012 --format json mass resource download [resource-id] [flags] ``` -### Examples - -``` - # Download resource using UUID (imported resources) - mass resource download 12345678-1234-1234-1234-123456789012 - - # Download resource using friendly slug (provisioned resources) - mass resource download api-prod-database-connection - mass resource download network-useast1-vpc-network -f yaml -``` - ### Options ``` diff --git a/docs/generated/mass_resource_get.md b/docs/generated/mass_resource_get.md index 7c9a4ef9..e0842c46 100644 --- a/docs/generated/mass_resource_get.md +++ b/docs/generated/mass_resource_get.md @@ -60,17 +60,6 @@ mass resource get api-prod-grpcapi-host -o json mass resource get [resource-id] [flags] ``` -### Examples - -``` - # Get resource using UUID (imported resources) - mass resource get 12345678-1234-1234-1234-123456789012 - - # Get resource using friendly slug (provisioned resources) - mass resource get api-prod-database-connection - mass resource get api-prod-grpcapi-host -o json -``` - ### Options ``` diff --git a/docs/generated/mass_resource_grant.md b/docs/generated/mass_resource_grant.md new file mode 100644 index 00000000..c3bf5335 --- /dev/null +++ b/docs/generated/mass_resource_grant.md @@ -0,0 +1,46 @@ +--- +id: mass_resource_grant.md +slug: /cli/commands/mass_resource_grant +title: Mass Resource Grant +sidebar_label: Mass Resource Grant +--- +## mass resource grant + +Manage sharing grants on a resource + +### Synopsis + +# Resource Grants + +Manages the sharing grants on a resource. + +A grant says "this resource is shared as ``, to recipient environments matching ``." Without a grant, a resource is visible only where it was created. + +Grants are immutable. To change an action or its conditions, delete the grant and create a replacement. + +## Usage + +```bash +mass resource grant create [flags] +mass resource grant list [flags] +mass resource grant delete +``` + +## Notes + +- Creating or deleting a grant requires the `resource:grant` action on the resource. +- Recipients are matched on **environment** attributes. Repository grants match on project attributes instead — see `mass repository grant`. + + +### Options + +``` + -h, --help help for grant +``` + +### SEE ALSO + +* [mass resource](/cli/commands/mass_resource) - Manage resources +* [mass resource grant create](/cli/commands/mass_resource_grant_create) - Share a resource with recipient environments +* [mass resource grant delete](/cli/commands/mass_resource_grant_delete) - Revoke a sharing grant by id +* [mass resource grant list](/cli/commands/mass_resource_grant_list) - List the sharing grants on a resource diff --git a/docs/generated/mass_resource_grant_create.md b/docs/generated/mass_resource_grant_create.md new file mode 100644 index 00000000..1cbd8d76 --- /dev/null +++ b/docs/generated/mass_resource_grant_create.md @@ -0,0 +1,89 @@ +--- +id: mass_resource_grant_create.md +slug: /cli/commands/mass_resource_grant_create +title: Mass Resource Grant Create +sidebar_label: Mass Resource Grant Create +--- +## mass resource grant create + +Share a resource with recipient environments + +### Synopsis + +# Create Resource Grant + +Shares a resource with recipient environments, so they can export it. + +Recipients are chosen by matching **environment** attributes. You must say who the recipients are: either name conditions with `--condition` / `--conditions-file`, or share with the whole organization with `--all-environments`. Leaving it unstated is an error rather than a silent org-wide grant. + +## Usage + +```bash +mass resource grant create [flags] +``` + +## Flags + +- `--condition` — a recipient environment attribute condition, `key=value`. Repeat the same key to accept a set of values; write `key=*` to accept any value as long as the attribute is set. +- `--conditions-file` — read conditions from a JSON file instead +- `--all-environments` — share with every environment in the organization +- `--action` — the action to grant. Defaults to `resource:export`, currently the only grantable resource action. + +`--condition`, `--conditions-file`, and `--all-environments` are mutually exclusive. + +## Examples + +```bash +# Share with environments whose stage attribute is dev or staging +mass resource grant create api-prod-database-connection \ + --condition stage=dev \ + --condition stage=staging + +# Share with environments that have any region attribute set +mass resource grant create api-prod-database-connection --condition 'region=*' + +# Share with every environment in the organization +mass resource grant create api-prod-database-connection --all-environments + +# Read conditions from a JSON file +mass resource grant create api-prod-database-connection --conditions-file ./conditions.json +``` + +## Conditions file format + +One JSON object, shaped like a grant's `recipientConditions` in `mass resource grant list -o json`. Values are either `"*"` for the per-key wildcard or an array of accepted values: + +```json +{ + "stage": ["dev", "staging"], + "region": "*" +} +``` + +A file holding `null` or `{}` is rejected — use `--all-environments` to share organization-wide, so the broadest grant is always explicit. + +## Notes + +- The `` is a UUID for imported resources, or a friendly slug for provisioned ones. +- Quote `key=*` so your shell does not expand the `*`. +- A comma is an ordinary character in a condition value. Unlike `--attributes`, it does not separate pairs. +- Grants are immutable; to change one, delete it and create a replacement. + + +``` +mass resource grant create [flags] +``` + +### Options + +``` + --action string Action to grant (default resource:export) + --all-environments Share with every environment in the organization + --condition stringArray Recipient environment attribute condition; repeat a key to accept a set of values, or write key=* to accept any value + --conditions-file string Read recipient conditions from a JSON file + -h, --help help for create +``` + +### SEE ALSO + +* [mass resource grant](/cli/commands/mass_resource_grant) - Manage sharing grants on a resource diff --git a/docs/generated/mass_resource_grant_delete.md b/docs/generated/mass_resource_grant_delete.md new file mode 100644 index 00000000..b96fa52a --- /dev/null +++ b/docs/generated/mass_resource_grant_delete.md @@ -0,0 +1,49 @@ +--- +id: mass_resource_grant_delete.md +slug: /cli/commands/mass_resource_grant_delete +title: Mass Resource Grant Delete +sidebar_label: Mass Resource Grant Delete +--- +## mass resource grant delete + +Revoke a sharing grant by id + +### Synopsis + +# Delete Resource Grant + +Revokes a sharing grant by id. Environments that qualified only through this grant immediately lose access to the resource. + +## Usage + +```bash +mass resource grant delete +``` + +## Examples + +```bash +# Find the grant id, then revoke it +mass resource grant list api-prod-database-connection +mass resource grant delete 4f2a9c18-1f0e-4a5c-9a3e-2b6d7c8e9f10 +``` + +## Notes + +- Grants are immutable, so changing one means deleting it and creating a replacement. +- This does not prompt for confirmation. A revoked grant can be re-created with `mass resource grant create`. + + +``` +mass resource grant delete [flags] +``` + +### Options + +``` + -h, --help help for delete +``` + +### SEE ALSO + +* [mass resource grant](/cli/commands/mass_resource_grant) - Manage sharing grants on a resource diff --git a/docs/generated/mass_resource_grant_list.md b/docs/generated/mass_resource_grant_list.md new file mode 100644 index 00000000..d08c8621 --- /dev/null +++ b/docs/generated/mass_resource_grant_list.md @@ -0,0 +1,58 @@ +--- +id: mass_resource_grant_list.md +slug: /cli/commands/mass_resource_grant_list +title: Mass Resource Grant List +sidebar_label: Mass Resource Grant List +--- +## mass resource grant list + +List the sharing grants on a resource + +### Synopsis + +# List Resource Grants + +Lists the sharing grants authored on a resource — what it is shared as, and which recipient environments qualify. + +If you can see a resource you can see all of its grants; they are publisher-side metadata rather than being visibility-gated themselves. + +## Usage + +```bash +mass resource grant list [flags] +``` + +## Flags + +- `-o, --output` — output format: `table` or `json` + +## Examples + +```bash +# List the grants on a resource +mass resource grant list api-prod-database-connection + +# As JSON, for scripting +mass resource grant list api-prod-database-connection -o json +``` + +## Notes + +- The `Recipients` column reads `everyone` for an organization-wide grant, `key=*` for a per-key wildcard, and `key=a|b` for a closed set. +- The `ID` column is what `mass resource grant delete` takes. + + +``` +mass resource grant list [flags] +``` + +### Options + +``` + -h, --help help for list + -o, --output string Output format (table, json) (default "table") +``` + +### SEE ALSO + +* [mass resource grant](/cli/commands/mass_resource_grant) - Manage sharing grants on a resource diff --git a/docs/generated/mass_resource_list.md b/docs/generated/mass_resource_list.md index 25e8a661..608697a5 100644 --- a/docs/generated/mass_resource_list.md +++ b/docs/generated/mass_resource_list.md @@ -55,18 +55,6 @@ mass resource list --environment ecomm-prod -o json mass resource list [flags] ``` -### Examples - -``` - # List all resources - mass resource list - - # Search and filter - mass resource list --search database - mass resource list --type aws-iam-role --origin provisioned - mass resource list --environment ecomm-prod -o json -``` - ### Options ``` diff --git a/docs/generated/mass_resource_update.md b/docs/generated/mass_resource_update.md index 8f5f64cf..5dbdf560 100644 --- a/docs/generated/mass_resource_update.md +++ b/docs/generated/mass_resource_update.md @@ -17,23 +17,21 @@ Update the payload of an imported resource. This command only works for imported ## Examples ```shell -mass resource update -f -mass resource update -f -n +# Update the resource payload +mass resource update 12345678-1234-1234-1234-123456789012 -f resource.json + +# Update the payload and rename the resource +mass resource update 12345678-1234-1234-1234-123456789012 -f resource.json -n new-name ``` +## Options -``` -mass resource update [resource-id] [flags] -``` +- `--file, -f`: Path to the JSON file holding the new payload +- `--name, -n`: New name for the resource -### Examples ``` - # Update resource payload - mass resource update 12345678-1234-1234-1234-123456789012 -f resource.json - - # Update resource payload and rename - mass resource update 12345678-1234-1234-1234-123456789012 -f resource.json -n new-name +mass resource update [resource-id] [flags] ``` ### Options diff --git a/docs/helpdocs/bundle/create.md b/docs/helpdocs/bundle/create.md new file mode 100644 index 00000000..f8aedcd8 --- /dev/null +++ b/docs/helpdocs/bundle/create.md @@ -0,0 +1,28 @@ +# Create Bundle Repository + +Creates an empty bundle OCI repository in your organization's catalog. The repository holds published versions of a bundle; create it before the first `mass bundle publish`. + +## Usage + +```bash +mass bundle create [flags] +``` + +## Flags + +- `-a, --attributes` — custom attributes for ABAC (repeat or comma-separate) + +## Examples + +```bash +# Create a repository for an Aurora Postgres bundle +mass bundle create aws-aurora-postgres + +# Tag it with attributes policies and grants can match on +mass bundle create aws-aurora-postgres -a owner=data,service=database +``` + +## Notes + +- This is `mass repository create --type bundle` with the type filled in. +- Share the repository with other projects using `mass repository grant create`. diff --git a/docs/helpdocs/component/update.md b/docs/helpdocs/component/update.md new file mode 100644 index 00000000..861e16f1 --- /dev/null +++ b/docs/helpdocs/component/update.md @@ -0,0 +1,30 @@ +# Update Component + +Updates a component's display name, description, or custom attributes. + +Attributes are replaced wholesale rather than merged, so pass every attribute you want to keep. + +## Usage + +```bash +mass component update [flags] +``` + +## Flags + +- `-n, --name` — new display name +- `-d, --description` — new description +- `-a, --attributes` — replacement custom attributes (repeat or comma-separate) + +## Examples + +```bash +# Rename a component +mass component update ecomm-db --name "Primary DB" + +# Rename and retag it in one call +mass component update ecomm-db --name "Primary DB" -a priority=high + +# Replace the full attribute set +mass component update ecomm-db -a priority=high,cost-center=engineering +``` diff --git a/docs/helpdocs/environment/deploy.md b/docs/helpdocs/environment/deploy.md index 5a6935e0..b6dd7064 100644 --- a/docs/helpdocs/environment/deploy.md +++ b/docs/helpdocs/environment/deploy.md @@ -33,4 +33,7 @@ mass environment deploy ecomm-staging # Deploy a freshly-forked preview env. mass environment fork ecomm-production pr42 --copy-environment-defaults mass environment deploy ecomm-pr42 + +# Stream every instance's logs until the rollout finishes. +mass environment deploy ecomm-staging --follow ``` diff --git a/docs/helpdocs/instance/destroy.md b/docs/helpdocs/instance/destroy.md new file mode 100644 index 00000000..89dd39f6 --- /dev/null +++ b/docs/helpdocs/instance/destroy.md @@ -0,0 +1,30 @@ +# Destroy Instance + +Destroys (decommissions) an instance. This permanently deletes the instance and all the resources it provisioned. + +## Usage + +```bash +mass instance destroy -- [flags] +``` + +## Flags + +- `-m, --message` — add a message when decommissioning +- `-f, --force` — skip the confirmation prompt +- `-p, --params` — path to a params json, tfvars or yaml file; `-` reads stdin +- `-P, --patch` — patch the last deployed configuration with a JQ expression (repeatable) +- `--follow` — stream the deployment's logs to stdout until it completes + +## Examples + +```bash +# Destroy an instance, confirming at the prompt +mass instance destroy api-prod-db + +# Skip the prompt, for scripted teardown +mass instance destroy api-prod-db --force + +# Destroy and watch the logs until it finishes +mass instance destroy api-prod-db --force --follow +``` diff --git a/docs/helpdocs/repository/grant-create.md b/docs/helpdocs/repository/grant-create.md new file mode 100644 index 00000000..1b270123 --- /dev/null +++ b/docs/helpdocs/repository/grant-create.md @@ -0,0 +1,57 @@ +# Create Repository Grant + +Shares an OCI repository with recipient projects, so they can pull it. + +Recipients are chosen by matching **project** attributes. You must say who the recipients are: either name conditions with `--condition` / `--conditions-file`, or share with the whole organization with `--all-projects`. Leaving it unstated is an error rather than a silent org-wide grant. + +## Usage + +```bash +mass repository grant create [flags] +``` + +## Flags + +- `--condition` — a recipient project attribute condition, `key=value`. Repeat the same key to accept a set of values; write `key=*` to accept any value as long as the attribute is set. +- `--conditions-file` — read conditions from a JSON file instead +- `--all-projects` — share with every project in the organization +- `--action` — the action to grant. Defaults to `repo:pull`, currently the only grantable repository action. + +`--condition`, `--conditions-file`, and `--all-projects` are mutually exclusive. + +## Examples + +```bash +# Share with projects whose team attribute is platform or data +mass repository grant create aws-aurora-postgres \ + --condition team=platform \ + --condition team=data + +# Share with projects that have any region attribute set +mass repository grant create aws-aurora-postgres --condition 'region=*' + +# Share with every project in the organization +mass repository grant create aws-aurora-postgres --all-projects + +# Read conditions from a JSON file +mass repository grant create aws-aurora-postgres --conditions-file ./conditions.json +``` + +## Conditions file format + +One JSON object, shaped like a grant's `recipientConditions` in `mass repository grant list -o json`. Values are either `"*"` for the per-key wildcard or an array of accepted values: + +```json +{ + "team": ["platform", "data"], + "region": "*" +} +``` + +A file holding `null` or `{}` is rejected — use `--all-projects` to share organization-wide, so the broadest grant is always explicit. + +## Notes + +- Quote `key=*` so your shell does not expand the `*`. +- A comma is an ordinary character in a condition value. Unlike `--attributes`, it does not separate pairs. +- Grants are immutable; to change one, delete it and create a replacement. diff --git a/docs/helpdocs/repository/grant-delete.md b/docs/helpdocs/repository/grant-delete.md new file mode 100644 index 00000000..41e25d72 --- /dev/null +++ b/docs/helpdocs/repository/grant-delete.md @@ -0,0 +1,22 @@ +# Delete Repository Grant + +Revokes a sharing grant by id. Recipients that qualified only through this grant immediately lose access to the repository. + +## Usage + +```bash +mass repository grant delete +``` + +## Examples + +```bash +# Find the grant id, then revoke it +mass repository grant list aws-aurora-postgres +mass repository grant delete 4f2a9c18-1f0e-4a5c-9a3e-2b6d7c8e9f10 +``` + +## Notes + +- Grants are immutable, so changing one means deleting it and creating a replacement. +- This does not prompt for confirmation. A revoked grant can be re-created with `mass repository grant create`. diff --git a/docs/helpdocs/repository/grant-list.md b/docs/helpdocs/repository/grant-list.md new file mode 100644 index 00000000..b78c2963 --- /dev/null +++ b/docs/helpdocs/repository/grant-list.md @@ -0,0 +1,30 @@ +# List Repository Grants + +Lists the sharing grants authored on an OCI repository — what it is shared as, and which recipient projects qualify. + +If you can see a repository you can see all of its grants; they are publisher-side metadata rather than being visibility-gated themselves. + +## Usage + +```bash +mass repository grant list [flags] +``` + +## Flags + +- `-o, --output` — output format: `table` or `json` + +## Examples + +```bash +# List the grants on a repository +mass repository grant list aws-aurora-postgres + +# As JSON, for scripting +mass repository grant list aws-aurora-postgres -o json +``` + +## Notes + +- The `Recipients` column reads `everyone` for an organization-wide grant, `key=*` for a per-key wildcard, and `key=a|b` for a closed set. +- The `ID` column is what `mass repository grant delete` takes. diff --git a/docs/helpdocs/repository/grant.md b/docs/helpdocs/repository/grant.md new file mode 100644 index 00000000..57fdb1a2 --- /dev/null +++ b/docs/helpdocs/repository/grant.md @@ -0,0 +1,20 @@ +# Repository Grants + +Manages the sharing grants on an OCI repository. + +A grant says "this repository is shared as ``, to recipient projects matching ``." Without a grant, a repository is visible only where it was published. + +Grants are immutable. To change an action or its conditions, delete the grant and create a replacement. + +## Usage + +```bash +mass repository grant create [flags] +mass repository grant list [flags] +mass repository grant delete +``` + +## Notes + +- Creating or deleting a grant requires the `repo:grant` action on the repository. +- Recipients are matched on **project** attributes. Resource grants match on environment attributes instead — see `mass resource grant`. diff --git a/docs/helpdocs/resource/delete.md b/docs/helpdocs/resource/delete.md new file mode 100644 index 00000000..9655baf9 --- /dev/null +++ b/docs/helpdocs/resource/delete.md @@ -0,0 +1,25 @@ +# Delete Resource + +Deletes an imported resource from your organization. + +Only imported resources can be deleted this way. Provisioned resources belong to the deployment that created them — remove those with `mass instance destroy`. + +## Usage + +```bash +mass resource delete [flags] +``` + +## Flags + +- `-f, --force` — skip the confirmation prompt + +## Examples + +```bash +# Delete an imported resource, confirming at the prompt +mass resource delete 12345678-1234-1234-1234-123456789012 + +# Skip the confirmation prompt +mass resource delete 12345678-1234-1234-1234-123456789012 --force +``` diff --git a/docs/helpdocs/resource/grant-create.md b/docs/helpdocs/resource/grant-create.md new file mode 100644 index 00000000..3e6a90c8 --- /dev/null +++ b/docs/helpdocs/resource/grant-create.md @@ -0,0 +1,58 @@ +# Create Resource Grant + +Shares a resource with recipient environments, so they can export it. + +Recipients are chosen by matching **environment** attributes. You must say who the recipients are: either name conditions with `--condition` / `--conditions-file`, or share with the whole organization with `--all-environments`. Leaving it unstated is an error rather than a silent org-wide grant. + +## Usage + +```bash +mass resource grant create [flags] +``` + +## Flags + +- `--condition` — a recipient environment attribute condition, `key=value`. Repeat the same key to accept a set of values; write `key=*` to accept any value as long as the attribute is set. +- `--conditions-file` — read conditions from a JSON file instead +- `--all-environments` — share with every environment in the organization +- `--action` — the action to grant. Defaults to `resource:export`, currently the only grantable resource action. + +`--condition`, `--conditions-file`, and `--all-environments` are mutually exclusive. + +## Examples + +```bash +# Share with environments whose stage attribute is dev or staging +mass resource grant create api-prod-database-connection \ + --condition stage=dev \ + --condition stage=staging + +# Share with environments that have any region attribute set +mass resource grant create api-prod-database-connection --condition 'region=*' + +# Share with every environment in the organization +mass resource grant create api-prod-database-connection --all-environments + +# Read conditions from a JSON file +mass resource grant create api-prod-database-connection --conditions-file ./conditions.json +``` + +## Conditions file format + +One JSON object, shaped like a grant's `recipientConditions` in `mass resource grant list -o json`. Values are either `"*"` for the per-key wildcard or an array of accepted values: + +```json +{ + "stage": ["dev", "staging"], + "region": "*" +} +``` + +A file holding `null` or `{}` is rejected — use `--all-environments` to share organization-wide, so the broadest grant is always explicit. + +## Notes + +- The `` is a UUID for imported resources, or a friendly slug for provisioned ones. +- Quote `key=*` so your shell does not expand the `*`. +- A comma is an ordinary character in a condition value. Unlike `--attributes`, it does not separate pairs. +- Grants are immutable; to change one, delete it and create a replacement. diff --git a/docs/helpdocs/resource/grant-delete.md b/docs/helpdocs/resource/grant-delete.md new file mode 100644 index 00000000..dcde434f --- /dev/null +++ b/docs/helpdocs/resource/grant-delete.md @@ -0,0 +1,22 @@ +# Delete Resource Grant + +Revokes a sharing grant by id. Environments that qualified only through this grant immediately lose access to the resource. + +## Usage + +```bash +mass resource grant delete +``` + +## Examples + +```bash +# Find the grant id, then revoke it +mass resource grant list api-prod-database-connection +mass resource grant delete 4f2a9c18-1f0e-4a5c-9a3e-2b6d7c8e9f10 +``` + +## Notes + +- Grants are immutable, so changing one means deleting it and creating a replacement. +- This does not prompt for confirmation. A revoked grant can be re-created with `mass resource grant create`. diff --git a/docs/helpdocs/resource/grant-list.md b/docs/helpdocs/resource/grant-list.md new file mode 100644 index 00000000..17ada82a --- /dev/null +++ b/docs/helpdocs/resource/grant-list.md @@ -0,0 +1,30 @@ +# List Resource Grants + +Lists the sharing grants authored on a resource — what it is shared as, and which recipient environments qualify. + +If you can see a resource you can see all of its grants; they are publisher-side metadata rather than being visibility-gated themselves. + +## Usage + +```bash +mass resource grant list [flags] +``` + +## Flags + +- `-o, --output` — output format: `table` or `json` + +## Examples + +```bash +# List the grants on a resource +mass resource grant list api-prod-database-connection + +# As JSON, for scripting +mass resource grant list api-prod-database-connection -o json +``` + +## Notes + +- The `Recipients` column reads `everyone` for an organization-wide grant, `key=*` for a per-key wildcard, and `key=a|b` for a closed set. +- The `ID` column is what `mass resource grant delete` takes. diff --git a/docs/helpdocs/resource/grant.md b/docs/helpdocs/resource/grant.md new file mode 100644 index 00000000..5aca768a --- /dev/null +++ b/docs/helpdocs/resource/grant.md @@ -0,0 +1,20 @@ +# Resource Grants + +Manages the sharing grants on a resource. + +A grant says "this resource is shared as ``, to recipient environments matching ``." Without a grant, a resource is visible only where it was created. + +Grants are immutable. To change an action or its conditions, delete the grant and create a replacement. + +## Usage + +```bash +mass resource grant create [flags] +mass resource grant list [flags] +mass resource grant delete +``` + +## Notes + +- Creating or deleting a grant requires the `resource:grant` action on the resource. +- Recipients are matched on **environment** attributes. Repository grants match on project attributes instead — see `mass repository grant`. diff --git a/docs/helpdocs/resource/update.md b/docs/helpdocs/resource/update.md index 939304f9..a15ab387 100644 --- a/docs/helpdocs/resource/update.md +++ b/docs/helpdocs/resource/update.md @@ -5,6 +5,14 @@ Update the payload of an imported resource. This command only works for imported ## Examples ```shell -mass resource update -f -mass resource update -f -n +# Update the resource payload +mass resource update 12345678-1234-1234-1234-123456789012 -f resource.json + +# Update the payload and rename the resource +mass resource update 12345678-1234-1234-1234-123456789012 -f resource.json -n new-name ``` + +## Options + +- `--file, -f`: Path to the JSON file holding the new payload +- `--name, -n`: New name for the resource diff --git a/internal/commands/grants/actions.go b/internal/commands/grants/actions.go new file mode 100644 index 00000000..73bc4295 --- /dev/null +++ b/internal/commands/grants/actions.go @@ -0,0 +1,38 @@ +package grants + +import ( + "fmt" + "slices" + "strings" +) + +// The read path returns actions that cannot be granted (repo:view, repo:push, +// resource:view), so these gate --action only. +const ( + ActionRepoPull = "repo:pull" + ActionResourceExport = "resource:export" +) + +var ( + grantableRepoActions = []string{ActionRepoPull} + grantableResourceActions = []string{ActionResourceExport} +) + +func ResolveRepoAction(action string) (string, error) { + return resolveAction(action, ActionRepoPull, grantableRepoActions) +} + +func ResolveResourceAction(action string) (string, error) { + return resolveAction(action, ActionResourceExport, grantableResourceActions) +} + +func resolveAction(action, fallback string, grantable []string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(action)) + if normalized == "" { + return fallback, nil + } + if slices.Contains(grantable, normalized) { + return normalized, nil + } + return "", fmt.Errorf("unknown action %q (valid: %s)", action, strings.Join(grantable, ", ")) +} diff --git a/internal/commands/grants/actions_test.go b/internal/commands/grants/actions_test.go new file mode 100644 index 00000000..db921ba2 --- /dev/null +++ b/internal/commands/grants/actions_test.go @@ -0,0 +1,67 @@ +package grants_test + +import ( + "strings" + "testing" + + "github.com/massdriver-cloud/mass/internal/commands/grants" +) + +func TestResolveActionDefaults(t *testing.T) { + repoAction, err := grants.ResolveRepoAction("") + if err != nil { + t.Fatalf("ResolveRepoAction returned error: %v", err) + } + if repoAction != grants.ActionRepoPull { + t.Errorf("ResolveRepoAction(\"\") = %q, want %q", repoAction, grants.ActionRepoPull) + } + + resourceAction, err := grants.ResolveResourceAction("") + if err != nil { + t.Fatalf("ResolveResourceAction returned error: %v", err) + } + if resourceAction != grants.ActionResourceExport { + t.Errorf("ResolveResourceAction(\"\") = %q, want %q", resourceAction, grants.ActionResourceExport) + } +} + +func TestResolveActionNormalizes(t *testing.T) { + for _, input := range []string{"repo:pull", "REPO:PULL", " Repo:Pull "} { + t.Run(input, func(t *testing.T) { + got, err := grants.ResolveRepoAction(input) + if err != nil { + t.Fatalf("ResolveRepoAction(%q) returned error: %v", input, err) + } + if got != grants.ActionRepoPull { + t.Errorf("ResolveRepoAction(%q) = %q, want %q", input, got, grants.ActionRepoPull) + } + }) + } +} + +func TestResolveActionRejectsUngrantable(t *testing.T) { + cases := []struct { + name string + resolve func(string) (string, error) + action string + }{ + {"repo view is read-only", grants.ResolveRepoAction, "repo:view"}, + {"repo push is not a sharing concern", grants.ResolveRepoAction, "repo:push"}, + {"resource action on a repo", grants.ResolveRepoAction, grants.ActionResourceExport}, + {"resource view is read-only", grants.ResolveResourceAction, "resource:view"}, + {"repo action on a resource", grants.ResolveResourceAction, grants.ActionRepoPull}, + {"nonsense", grants.ResolveRepoAction, "repo:destroy"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.resolve(tc.action) + if err == nil { + t.Fatalf("resolving %q succeeded, wanted error", tc.action) + } + if !strings.Contains(err.Error(), "unknown action") { + t.Errorf("error = %q, want it to contain %q", err, "unknown action") + } + }) + } +} diff --git a/internal/commands/grants/conditions.go b/internal/commands/grants/conditions.go new file mode 100644 index 00000000..1d1df0d4 --- /dev/null +++ b/internal/commands/grants/conditions.go @@ -0,0 +1,138 @@ +package grants + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "slices" + "sort" + "strings" + + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" +) + +const wildcardValue = "*" + +// Repeating a key unions its values; only allFlag yields nil, the org-wide +// wildcard. +func ResolveConditions(pairs []string, file string, all bool, allFlag string) (types.PolicyConditions, error) { + supplied := 0 + for _, given := range []bool{len(pairs) > 0, file != "", all} { + if given { + supplied++ + } + } + + switch { + case supplied == 0: + return nil, fmt.Errorf("specify recipient conditions with --condition or --conditions-file, or share with your whole organization using --%s", allFlag) + case supplied > 1: + return nil, fmt.Errorf("--condition, --conditions-file, and --%s are mutually exclusive", allFlag) + case all: + //nolint:nilnil // nil conditions is the org-wide wildcard, not a missing value + return nil, nil + case file != "": + return ParseConditionsFile(file, allFlag) + default: + return ParseConditions(pairs) + } +} + +func ParseConditions(pairs []string) (types.PolicyConditions, error) { + if len(pairs) == 0 { + return nil, errors.New("no conditions given") + } + + conditions := types.PolicyConditions{} + wildcards := map[string]bool{} + + for _, pair := range pairs { + key, value, found := strings.Cut(pair, "=") + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + + switch { + case !found: + return nil, fmt.Errorf("condition %q must be formatted as key=value", pair) + case key == "": + return nil, fmt.Errorf("condition %q has an empty attribute name", pair) + case value == "": + return nil, fmt.Errorf("condition %q has an empty value; write %s=%s to accept any value", pair, key, wildcardValue) + } + + if value == wildcardValue { + if len(conditions[key]) > 0 { + return nil, conflictError(key) + } + wildcards[key] = true + conditions[key] = nil + continue + } + if wildcards[key] { + return nil, conflictError(key) + } + if !slices.Contains(conditions[key], value) { + conditions[key] = append(conditions[key], value) + } + } + + return conditions, nil +} + +func ParseConditionsFile(path, allFlag string) (types.PolicyConditions, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading conditions file: %w", err) + } + + var conditions types.PolicyConditions + if unmarshalErr := json.Unmarshal(data, &conditions); unmarshalErr != nil { + return nil, fmt.Errorf("parsing conditions file %s: %w", path, unmarshalErr) + } + + // `null` and `{}` decode to the org-wide wildcard, routing around allFlag. + if len(conditions) == 0 { + return nil, fmt.Errorf("conditions file %s describes no conditions; share with your whole organization using --%s", path, allFlag) + } + for key, values := range conditions { + if strings.TrimSpace(key) == "" { + return nil, fmt.Errorf("conditions file %s has an empty attribute name", path) + } + for i, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, fmt.Errorf("conditions file %s has an empty value for %q; use %q to accept any value", path, key, wildcardValue) + } + values[i] = trimmed + } + } + + return conditions, nil +} + +func FormatConditions(conditions types.PolicyConditions) string { + if len(conditions) == 0 { + return "everyone" + } + + keys := make([]string, 0, len(conditions)) + for key := range conditions { + keys = append(keys, key) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, key := range keys { + if len(conditions[key]) == 0 { + parts = append(parts, key+"="+wildcardValue) + continue + } + parts = append(parts, key+"="+strings.Join(conditions[key], "|")) + } + return strings.Join(parts, ", ") +} + +func conflictError(key string) error { + return fmt.Errorf("condition %q mixes %s=%s with specific values; use one or the other", key, key, wildcardValue) +} diff --git a/internal/commands/grants/conditions_test.go b/internal/commands/grants/conditions_test.go new file mode 100644 index 00000000..ed41fe48 --- /dev/null +++ b/internal/commands/grants/conditions_test.go @@ -0,0 +1,295 @@ +package grants_test + +import ( + "maps" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/massdriver-cloud/mass/internal/commands/grants" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" +) + +func TestParseConditions(t *testing.T) { + cases := []struct { + name string + pairs []string + want types.PolicyConditions + }{ + { + name: "single value", + pairs: []string{"team=platform"}, + want: types.PolicyConditions{"team": {"platform"}}, + }, + { + name: "repeated key unions into a closed set", + pairs: []string{"team=platform", "team=data"}, + want: types.PolicyConditions{"team": {"platform", "data"}}, + }, + { + name: "repeated identical value is deduped", + pairs: []string{"team=platform", "team=platform"}, + want: types.PolicyConditions{"team": {"platform"}}, + }, + { + name: "star is the per-key wildcard", + pairs: []string{"region=*"}, + want: types.PolicyConditions{"region": nil}, + }, + { + name: "distinct keys are independent", + pairs: []string{"team=platform", "region=*", "stage=prod"}, + want: types.PolicyConditions{"team": {"platform"}, "region": nil, "stage": {"prod"}}, + }, + { + name: "value may contain an equals sign", + pairs: []string{"expr=a=b"}, + want: types.PolicyConditions{"expr": {"a=b"}}, + }, + { + // A comma splits pairs in --attributes; here it is just a character. + name: "value may contain a comma", + pairs: []string{"team=platform,data"}, + want: types.PolicyConditions{"team": {"platform,data"}}, + }, + { + name: "surrounding whitespace is trimmed", + pairs: []string{" team = platform "}, + want: types.PolicyConditions{"team": {"platform"}}, + }, + { + // Untrimmed this stores a literal " *", which matches no recipient. + name: "spaced wildcard is still a wildcard", + pairs: []string{"region= *"}, + want: types.PolicyConditions{"region": nil}, + }, + { + name: "inner whitespace is preserved", + pairs: []string{"team=platform team"}, + want: types.PolicyConditions{"team": {"platform team"}}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := grants.ParseConditions(tc.pairs) + if err != nil { + t.Fatalf("ParseConditions(%v) returned error: %v", tc.pairs, err) + } + if !conditionsEqual(got, tc.want) { + t.Errorf("ParseConditions(%v) = %v, want %v", tc.pairs, got, tc.want) + } + }) + } +} + +func TestParseConditionsErrors(t *testing.T) { + cases := []struct { + name string + pairs []string + wantError string + }{ + {"no pairs", nil, "no conditions given"}, + {"missing equals", []string{"team"}, "must be formatted as key=value"}, + {"empty key", []string{"=platform"}, "empty attribute name"}, + {"empty value", []string{"team="}, "empty value"}, + {"whitespace-only value", []string{"team= "}, "empty value"}, + {"wildcard then specific", []string{"team=*", "team=platform"}, "use one or the other"}, + {"specific then wildcard", []string{"team=platform", "team=*"}, "use one or the other"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := grants.ParseConditions(tc.pairs) + if err == nil { + t.Fatalf("ParseConditions(%v) succeeded, wanted error", tc.pairs) + } + if !strings.Contains(err.Error(), tc.wantError) { + t.Errorf("ParseConditions(%v) error = %q, want it to contain %q", tc.pairs, err, tc.wantError) + } + }) + } +} + +func TestParseConditionsRepeatedWildcard(t *testing.T) { + got, err := grants.ParseConditions([]string{"region=*", "region=*"}) + if err != nil { + t.Fatalf("ParseConditions returned error: %v", err) + } + if !conditionsEqual(got, types.PolicyConditions{"region": nil}) { + t.Errorf("ParseConditions = %v, want region wildcard", got) + } +} + +func TestResolveConditions(t *testing.T) { + conditions, err := grants.ResolveConditions([]string{"team=platform"}, "", false, "all-projects") + if err != nil { + t.Fatalf("ResolveConditions returned error: %v", err) + } + if !conditionsEqual(conditions, types.PolicyConditions{"team": {"platform"}}) { + t.Errorf("ResolveConditions = %v, want team=platform", conditions) + } +} + +func TestResolveConditionsAllIsTheOnlyWildcard(t *testing.T) { + conditions, err := grants.ResolveConditions(nil, "", true, "all-projects") + if err != nil { + t.Fatalf("ResolveConditions returned error: %v", err) + } + if conditions != nil { + t.Errorf("ResolveConditions(all) = %v, want nil", conditions) + } +} + +func TestResolveConditionsErrors(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "conditions.json") + if err := os.WriteFile(file, []byte(`{"team":["platform"]}`), 0600); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + pairs []string + file string + all bool + wantError string + }{ + {"nothing given", nil, "", false, "--all-projects"}, + {"pairs and file", []string{"team=platform"}, file, false, "mutually exclusive"}, + {"pairs and all", []string{"team=platform"}, "", true, "mutually exclusive"}, + {"file and all", nil, file, true, "mutually exclusive"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := grants.ResolveConditions(tc.pairs, tc.file, tc.all, "all-projects") + if err == nil { + t.Fatal("ResolveConditions succeeded, wanted error") + } + if !strings.Contains(err.Error(), tc.wantError) { + t.Errorf("ResolveConditions error = %q, want it to contain %q", err, tc.wantError) + } + }) + } +} + +func TestParseConditionsFile(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "conditions.json") + if err := os.WriteFile(file, []byte(`{"team":["platform"," data "],"region":"*"}`), 0600); err != nil { + t.Fatal(err) + } + + got, err := grants.ParseConditionsFile(file, "all-projects") + if err != nil { + t.Fatalf("ParseConditionsFile returned error: %v", err) + } + want := types.PolicyConditions{"team": {"platform", "data"}, "region": nil} + if !conditionsEqual(got, want) { + t.Errorf("ParseConditionsFile = %v, want %v", got, want) + } +} + +func TestParseConditionsFileRejectsWildcard(t *testing.T) { + dir := t.TempDir() + + for _, body := range []string{"null", "{}"} { + t.Run(body, func(t *testing.T) { + file := filepath.Join(dir, "wildcard.json") + if err := os.WriteFile(file, []byte(body), 0600); err != nil { + t.Fatal(err) + } + _, err := grants.ParseConditionsFile(file, "all-projects") + if err == nil { + t.Fatal("ParseConditionsFile succeeded, wanted error") + } + if !strings.Contains(err.Error(), "--all-projects") { + t.Errorf("error = %q, want it to name --all-projects", err) + } + }) + } +} + +func TestParseConditionsFileErrors(t *testing.T) { + dir := t.TempDir() + + malformed := filepath.Join(dir, "malformed.json") + if err := os.WriteFile(malformed, []byte("{not json"), 0600); err != nil { + t.Fatal(err) + } + emptyKey := filepath.Join(dir, "empty-key.json") + if err := os.WriteFile(emptyKey, []byte(`{"":["platform"]}`), 0600); err != nil { + t.Fatal(err) + } + emptyValue := filepath.Join(dir, "empty-value.json") + if err := os.WriteFile(emptyValue, []byte(`{"team":[" "]}`), 0600); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + path string + wantError string + }{ + {"missing file", filepath.Join(dir, "absent.json"), "reading conditions file"}, + {"malformed json", malformed, "parsing conditions file"}, + {"empty attribute name", emptyKey, "empty attribute name"}, + {"empty value", emptyValue, "empty value"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := grants.ParseConditionsFile(tc.path, "all-projects") + if err == nil { + t.Fatal("ParseConditionsFile succeeded, wanted error") + } + if !strings.Contains(err.Error(), tc.wantError) { + t.Errorf("error = %q, want it to contain %q", err, tc.wantError) + } + }) + } +} + +func TestFormatConditions(t *testing.T) { + cases := []struct { + name string + conditions types.PolicyConditions + want string + }{ + {"nil is org-wide", nil, "everyone"}, + {"empty map is org-wide", types.PolicyConditions{}, "everyone"}, + {"per-key wildcard", types.PolicyConditions{"region": nil}, "region=*"}, + {"empty slice is a wildcard too", types.PolicyConditions{"region": {}}, "region=*"}, + {"closed set", types.PolicyConditions{"team": {"platform", "data"}}, "team=platform|data"}, + { + name: "keys are sorted for stable output", + conditions: types.PolicyConditions{"team": {"platform"}, "region": nil, "stage": {"prod"}}, + want: "region=*, stage=prod, team=platform", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := grants.FormatConditions(tc.conditions); got != tc.want { + t.Errorf("FormatConditions(%v) = %q, want %q", tc.conditions, got, tc.want) + } + }) + } +} + +// A nil and an empty value slice are the same per-key wildcard. +func conditionsEqual(got, want types.PolicyConditions) bool { + if (got == nil) != (want == nil) || len(got) != len(want) { + return false + } + for key := range maps.Keys(want) { + gotValues, ok := got[key] + if !ok || !slices.Equal(gotValues, want[key]) { + return false + } + } + return true +} diff --git a/internal/commands/grants/grants.go b/internal/commands/grants/grants.go new file mode 100644 index 00000000..1174f7b7 --- /dev/null +++ b/internal/commands/grants/grants.go @@ -0,0 +1,67 @@ +// Package grants holds the logic behind `mass repository grant` and +// `mass resource grant`. +package grants + +import ( + "encoding/json" + "errors" + "fmt" + "iter" + "os" + "regexp" + + "github.com/massdriver-cloud/mass/internal/cli" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" +) + +// out doubles as the interactivity signal: a TTY gets the pager, anything +// else a streamed table. +func Render(seq iter.Seq2[types.Grant, error], output string, out *os.File) error { + switch output { + case "json": + items, collectErr := types.Collect(seq) + if collectErr != nil { + return collectErr + } + jsonBytes, marshalErr := json.MarshalIndent(items, "", " ") + if marshalErr != nil { + return fmt.Errorf("failed to marshal grants to JSON: %w", marshalErr) + } + fmt.Fprintln(out, string(jsonBytes)) + return nil + case "table": + return cli.Paginate(seq, cli.PagerConfig[types.Grant]{ + Out: out, + Columns: []string{"ID", "Action", "Recipients", "Created At"}, + Row: func(grant types.Grant) []string { + return []string{ + grant.ID, + grant.Action, + FormatConditions(grant.RecipientConditions), + grant.CreatedAt.Format("2006-01-02 15:04:05"), + } + }, + }) + default: + return fmt.Errorf("unsupported output format: %s", output) + } +} + +var grantIDPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// The server rejects a non-UUID id with a raw GraphQL parse error, so catch it +// here. listCmd names the command that lists ids. +func ValidateGrantID(id, listCmd string) error { + if !grantIDPattern.MatchString(id) { + return fmt.Errorf("%q is not a grant id; run `%s` to find it", id, listCmd) + } + return nil +} + +func NotFoundHint(err error, kind, id string) error { + if !errors.Is(err, gql.ErrNotFound) { + return err + } + return fmt.Errorf("%s %q does not exist", kind, id) +} diff --git a/internal/commands/grants/grants_test.go b/internal/commands/grants/grants_test.go new file mode 100644 index 00000000..7312cb20 --- /dev/null +++ b/internal/commands/grants/grants_test.go @@ -0,0 +1,200 @@ +package grants_test + +import ( + "encoding/json" + "errors" + "fmt" + "iter" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/massdriver-cloud/mass/internal/commands/grants" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" +) + +func TestRenderTable(t *testing.T) { + grant := types.Grant{ + ID: "grant-1", + Action: grants.ActionRepoPull, + RecipientConditions: types.PolicyConditions{"team": {"platform", "data"}}, + CreatedAt: time.Date(2026, 9, 10, 12, 30, 0, 0, time.UTC), + } + + out, err := renderTo(t, func(out *os.File) error { + return grants.Render(grantSeq(grant), "table", out) + }) + if err != nil { + t.Fatalf("Render returned error: %v", err) + } + + for _, want := range []string{"grant-1", grants.ActionRepoPull, "team=platform|data", "2026-09-10 12:30:00"} { + if !strings.Contains(out, want) { + t.Errorf("table output missing %q:\n%s", want, out) + } + } +} + +func TestRenderTableNamesTheWildcard(t *testing.T) { + out, err := renderTo(t, func(out *os.File) error { + return grants.Render(grantSeq(types.Grant{ID: "grant-1", Action: grants.ActionRepoPull}), "table", out) + }) + if err != nil { + t.Fatalf("Render returned error: %v", err) + } + if !strings.Contains(out, "everyone") { + t.Errorf("table output missing %q:\n%s", "everyone", out) + } +} + +func TestRenderJSONConditionsRoundTripThroughFile(t *testing.T) { + want := types.PolicyConditions{"team": {"platform", "data"}, "region": nil} + + out, err := renderTo(t, func(out *os.File) error { + return grants.Render(grantSeq(types.Grant{ID: "grant-1", RecipientConditions: want}), "json", out) + }) + if err != nil { + t.Fatalf("Render returned error: %v", err) + } + + var rendered []struct { + RecipientConditions json.RawMessage `json:"recipientConditions"` + } + if unmarshalErr := json.Unmarshal([]byte(out), &rendered); unmarshalErr != nil { + t.Fatalf("rendered JSON did not parse: %v\n%s", unmarshalErr, out) + } + if len(rendered) != 1 { + t.Fatalf("rendered %d grants, want 1:\n%s", len(rendered), out) + } + + file := filepath.Join(t.TempDir(), "conditions.json") + if writeErr := os.WriteFile(file, rendered[0].RecipientConditions, 0600); writeErr != nil { + t.Fatal(writeErr) + } + + got, err := grants.ParseConditionsFile(file, "all-projects") + if err != nil { + t.Fatalf("ParseConditionsFile on rendered conditions returned error: %v", err) + } + if !conditionsEqual(got, want) { + t.Errorf("round trip = %v, want %v", got, want) + } +} + +func TestRenderUnsupportedFormat(t *testing.T) { + err := grants.Render(grantSeq(), "yaml", os.Stdout) + if err == nil { + t.Fatal("Render succeeded, wanted error") + } + if !strings.Contains(err.Error(), "unsupported output format") { + t.Errorf("error = %q, want it to name the unsupported format", err) + } +} + +func TestRenderPropagatesIterationError(t *testing.T) { + pageErr := errors.New("page fetch failed") + + for _, format := range []string{"table", "json"} { + t.Run(format, func(t *testing.T) { + _, err := renderTo(t, func(out *os.File) error { + return grants.Render(failingSeq(pageErr), format, out) + }) + if !errors.Is(err, pageErr) { + t.Errorf("Render error = %v, want %v", err, pageErr) + } + }) + } +} + +func TestValidateGrantID(t *testing.T) { + if err := grants.ValidateGrantID("4f2a9c18-1f0e-4a5c-9a3e-2b6d7c8e9f10", "mass repository grant list "); err != nil { + t.Errorf("ValidateGrantID rejected a valid uuid: %v", err) + } + + for _, id := range []string{"not-a-uuid", "123", "", "4f2a9c18-1f0e-4a5c-9a3e-2b6d7c8e9f1", "4f2a9c18_1f0e_4a5c_9a3e_2b6d7c8e9f10"} { + t.Run(id, func(t *testing.T) { + err := grants.ValidateGrantID(id, "mass repository grant list ") + if err == nil { + t.Fatalf("ValidateGrantID(%q) succeeded, wanted error", id) + } + if !strings.Contains(err.Error(), "mass repository grant list") { + t.Errorf("error = %q, want it to name the list command", err) + } + }) + } +} + +func TestNotFoundHint(t *testing.T) { + err := grants.NotFoundHint(gql.ErrNotFound, "repository", "aws-aurora-postgres") + if err == nil { + t.Fatal("NotFoundHint dropped the error") + } + for _, want := range []string{"repository", "aws-aurora-postgres", "does not exist"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %q, want it to contain %q", err, want) + } + } +} + +func TestNotFoundHintUnwrapsWrappedErrors(t *testing.T) { + wrapped := fmt.Errorf("list oci repo grants: %w", gql.ErrNotFound) + err := grants.NotFoundHint(wrapped, "repository", "aws-aurora-postgres") + if !strings.Contains(err.Error(), "does not exist") { + t.Errorf("error = %q, want the not-found rewrite", err) + } +} + +func TestNotFoundHintPassesOtherErrorsThrough(t *testing.T) { + original := errors.New("permission denied") + if err := grants.NotFoundHint(original, "repository", "aws-aurora-postgres"); !errors.Is(err, original) { + t.Errorf("NotFoundHint = %v, want %v", err, original) + } +} + +func TestNotFoundHintNilStaysNil(t *testing.T) { + if err := grants.NotFoundHint(nil, "repository", "aws-aurora-postgres"); err != nil { + t.Errorf("NotFoundHint(nil) = %v, want nil", err) + } +} + +// A plain file is not a TTY, so the table path streams instead of paging. +func renderTo(t *testing.T, fn func(out *os.File) error) (string, error) { + t.Helper() + + path := filepath.Join(t.TempDir(), "render.out") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + + runErr := fn(file) + + if closeErr := file.Close(); closeErr != nil { + t.Fatal(closeErr) + } + out, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + + return string(out), runErr +} + +func grantSeq(items ...types.Grant) iter.Seq2[types.Grant, error] { + return func(yield func(types.Grant, error) bool) { + for _, item := range items { + if !yield(item, nil) { + return + } + } + } +} + +func failingSeq(err error) iter.Seq2[types.Grant, error] { + return func(yield func(types.Grant, error) bool) { + yield(types.Grant{}, err) + } +}