diff --git a/docs/components/Cloudsmith.mdx b/docs/components/Cloudsmith.mdx index 0d2ed68ba3..71731d6f04 100644 --- a/docs/components/Cloudsmith.mdx +++ b/docs/components/Cloudsmith.mdx @@ -19,6 +19,8 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + + @@ -35,6 +37,8 @@ SuperPlane authenticates to Cloudsmith using a service account API key, which is 4. Paste the API key below. 5. To give the service access to any repository, click on your Repository and then **Settings** → **Access control → Privileges for specific services**, and add the service with the **Admin** privilege. +> **Note:** The **Promote Package** action (copy or move a package between repositories) requires **Admin** privilege on **both** the source and destination repositories. Make sure the service account has been granted Admin access to every repository it needs to promote packages to or from. + ## On Package Created @@ -330,6 +334,157 @@ Returns the repository object including: } ``` + + +## List Packages + +**Component key:** `cloudsmith.listPackages` + +The List Packages component fetches all packages in a Cloudsmith repository and optionally filters them by sync status, quarantine state, or vulnerability scan result. + +### Use Cases + +- **Release auditing**: List all fully synchronized packages before a release gate +- **Quarantine monitoring**: Enumerate quarantined packages for a security review workflow +- **Vulnerability triage**: Retrieve packages with detected vulnerabilities and route them to a remediation step +- **Inventory**: Collect a complete snapshot of packages in a repository for reporting + +### Configuration + +- **Repository** (required): The repository to list packages from, in the form `owner/repository`. +- **Sync Status** (optional): Filter by package synchronization state (`Any`, `Fully Synchronised`, `Awaiting Sync`, `Sync Failed`). +- **Quarantine Status** (optional): Filter by quarantine state (`Any`, `Quarantined`, `Not Quarantined`). +- **Vulnerability Status** (optional): Filter by security scan result (`Any`, `No Vulnerabilities`, `Vulnerabilities Found`). + +### Output + +Emits a single payload containing a `packages` array. Each entry includes: +- **display_name** / **format**: Package display name and format +- **status_str** / **stage_str**: Human-readable status and sync stage +- **is_quarantined** / **policy_violated**: Quarantine and policy state +- **description** / **license**: Package description and license +- **slug_perm** / **repository**: Permanent identifier and repository slug +- **tags**: Package tags + +### Example Output + +```json +{ + "data": { + "packages": [ + { + "description": "Example application container image", + "display_name": "example-app", + "format": "docker", + "is_quarantined": false, + "license": "MIT", + "policy_violated": false, + "repository": "example-repo", + "security_scan_status": "No Vulnerabilities Found", + "slug_perm": "example-pkg-id-1", + "stage_str": "Fully Synchronised", + "status_str": "Available", + "tags": {} + }, + { + "description": "Example application container image (previous release)", + "display_name": "example-app", + "format": "docker", + "is_quarantined": true, + "license": "MIT", + "policy_violated": true, + "repository": "example-repo", + "security_scan_status": "Scan Detected Vulnerabilities", + "slug_perm": "example-pkg-id-2", + "stage_str": "Fully Synchronised", + "status_str": "Available", + "tags": {} + } + ] + }, + "timestamp": "2026-01-15T14:35:00Z", + "type": "cloudsmith.packages.listed" +} +``` + + + +## Promote Package + +**Component key:** `cloudsmith.promotePackage` + +The Promote Package component copies or moves a package from a source repository to a destination repository within the same Cloudsmith namespace. + +### Use Cases + +- **Promotion pipelines**: Move a package from staging to production after all checks pass +- **Multi-environment distribution**: Copy a package to multiple target repositories simultaneously +- **Artifact archiving**: Move packages from active repositories to archive repositories +- **Release management**: Promote a vetted version from a dev channel to a release channel + +### Configuration + +- **Source Repository** (required): The repository that currently holds the package, in the form `owner/repository`. +- **Package** (required): The unique package identifier (`slug_perm`). Supports expressions — use `{{ $['On Package Created'].data.slug_perm }}` to reference an upstream trigger. +- **Destination Repository** (required): The target repository to promote the package into, in the form `owner/repository`. +- **Mode** (required): Whether to `copy` (keep the original) or `move` (remove from source). + +### Output + +Returns the promoted package as it appears in the destination repository, including: +- **name** / **version**: Package name and version +- **format**: Package format (e.g., `docker`, `python`, `debian`) +- **repository** / **namespace**: Where the package now lives +- **self_webapp_url**: URL to the promoted package in the Cloudsmith web app +- **slug_perm**: Permanent identifier of the package in the destination +- **uploaded_at**: Original upload timestamp + +### Example Output + +```json +{ + "data": { + "cdn_url": "https://dl.cloudsmith.io/basic/example-owner/example-prod-repo/docker/example-app.manifest.json", + "checksum_md5": "00000000000000000000000000000000", + "checksum_sha1": "0000000000000000000000000000000000000000", + "checksum_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "checksum_sha512": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "display_name": "example-app", + "format": "docker", + "is_quarantined": false, + "is_sync_awaiting": false, + "is_sync_completed": true, + "is_sync_failed": false, + "is_sync_in_flight": false, + "is_sync_in_progress": false, + "name": "example-app", + "namespace": "example-owner", + "policy_violated": false, + "repository": "example-prod-repo", + "security_scan_status": "No Vulnerabilities Found", + "self_html_url": "https://cloudsmith.io/~example-owner/repos/example-prod-repo/packages/detail/docker/example-app/1.2.0/", + "self_url": "https://api.cloudsmith.io/v1/packages/example-owner/example-prod-repo/example-pkg-prod-id/", + "self_webapp_url": "https://app.cloudsmith.com/example-owner/r/example-prod-repo/docker/example-app/1.2.0/example-pkg-prod-id", + "size": 54525952, + "size_str": "52.0 MB", + "slug": "example-app-xyz9", + "slug_perm": "example-pkg-prod-id", + "stage": 9, + "stage_str": "Fully Synchronised", + "status": 2, + "status_str": "Available", + "sync_progress": 100, + "tags": {}, + "tags_immutable": {}, + "uploaded_at": "2026-01-15T14:30:00Z", + "uploader": "example-user", + "version": "1.2.0" + }, + "timestamp": "2026-01-15T15:00:00Z", + "type": "cloudsmith.package.promoted" +} +``` + ## Resync Package diff --git a/pkg/integrations/cloudsmith/client.go b/pkg/integrations/cloudsmith/client.go index 3e4ea1dd36..30d3cef17d 100644 --- a/pkg/integrations/cloudsmith/client.go +++ b/pkg/integrations/cloudsmith/client.go @@ -312,10 +312,20 @@ const packagePageSize = 100 // ListPackages returns all packages in the given repository, following pagination. func (c *Client) ListPackages(owner, repo string) ([]Package, error) { + return c.ListPackagesWithFilters(owner, repo, "") +} + +// ListPackagesWithFilters returns packages filtered by a Lucene-style query string, +// following pagination. An empty query returns all packages. +func (c *Client) ListPackagesWithFilters(owner, repo, query string) ([]Package, error) { var all []Package for page := 1; ; page++ { requestURL := fmt.Sprintf("%s/packages/%s/%s/?page=%d&page_size=%d", c.BaseURL, url.PathEscape(owner), url.PathEscape(repo), page, packagePageSize) + if query != "" { + requestURL += "&query=" + url.QueryEscape(query) + } + responseBody, err := c.execRequest(http.MethodGet, requestURL, nil) if err != nil { return nil, err @@ -336,6 +346,51 @@ func (c *Client) ListPackages(owner, repo string) ([]Package, error) { return all, nil } +// CopyPackage copies a package to a destination repository within the same namespace. +// Returns the copied package in the destination. +func (c *Client) CopyPackage(owner, repo, identifier, destinationRepo string) (*Package, error) { + payload, err := json.Marshal(map[string]string{"destination": destinationRepo}) + if err != nil { + return nil, fmt.Errorf("error encoding request: %v", err) + } + + requestURL := fmt.Sprintf("%s/packages/%s/%s/%s/copy/", c.BaseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(identifier)) + responseBody, err := c.execRequest(http.MethodPost, requestURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + + var pkg Package + if err := json.Unmarshal(responseBody, &pkg); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return &pkg, nil +} + +// MovePackage moves a package to a destination repository within the same namespace. +// The package is removed from the source repository after a successful move. +// Returns the moved package in the destination. +func (c *Client) MovePackage(owner, repo, identifier, destinationRepo string) (*Package, error) { + payload, err := json.Marshal(map[string]string{"destination": destinationRepo}) + if err != nil { + return nil, fmt.Errorf("error encoding request: %v", err) + } + + requestURL := fmt.Sprintf("%s/packages/%s/%s/%s/move/", c.BaseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(identifier)) + responseBody, err := c.execRequest(http.MethodPost, requestURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + + var pkg Package + if err := json.Unmarshal(responseBody, &pkg); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return &pkg, nil +} + var errInvalidRepositoryID = errors.New("must be in the form 'owner/repository'") // parseRepositoryID splits a Cloudsmith repository identifier of the form diff --git a/pkg/integrations/cloudsmith/cloudsmith.go b/pkg/integrations/cloudsmith/cloudsmith.go index 7f1e17e573..f50862ea31 100644 --- a/pkg/integrations/cloudsmith/cloudsmith.go +++ b/pkg/integrations/cloudsmith/cloudsmith.go @@ -51,6 +51,8 @@ SuperPlane authenticates to Cloudsmith using a service account API key, which is 3. Click on **Create Service** and copy the generated API key. 4. Paste the API key below. 5. To give the service access to any repository, click on your Repository and then **Settings** → **Access control → Privileges for specific services**, and add the service with the **Admin** privilege. + +> **Note:** The **Promote Package** action (copy or move a package between repositories) requires **Admin** privilege on **both** the source and destination repositories. Make sure the service account has been granted Admin access to every repository it needs to promote packages to or from. ` } @@ -74,6 +76,8 @@ func (c *Cloudsmith) Actions() []core.Action { &ResyncPackage{}, &TagPackage{}, &DeletePackage{}, + &ListPackages{}, + &PromotePackage{}, } } diff --git a/pkg/integrations/cloudsmith/example.go b/pkg/integrations/cloudsmith/example.go index b1ed38e597..01f9bacb05 100644 --- a/pkg/integrations/cloudsmith/example.go +++ b/pkg/integrations/cloudsmith/example.go @@ -55,6 +55,26 @@ func (r *ResyncPackage) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputResyncPackageOnce, exampleOutputResyncPackageBytes, &exampleOutputResyncPackage) } +//go:embed example_output_list_packages.json +var exampleOutputListPackagesBytes []byte + +var exampleOutputListPackagesOnce sync.Once +var exampleOutputListPackages map[string]any + +func (l *ListPackages) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputListPackagesOnce, exampleOutputListPackagesBytes, &exampleOutputListPackages) +} + +//go:embed example_output_promote_package.json +var exampleOutputPromotePackageBytes []byte + +var exampleOutputPromotePackageOnce sync.Once +var exampleOutputPromotePackage map[string]any + +func (p *PromotePackage) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputPromotePackageOnce, exampleOutputPromotePackageBytes, &exampleOutputPromotePackage) +} + func (t *TagPackage) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputTagPackageOnce, exampleOutputTagPackageBytes, &exampleOutputTagPackage) } diff --git a/pkg/integrations/cloudsmith/example_output_list_packages.json b/pkg/integrations/cloudsmith/example_output_list_packages.json new file mode 100644 index 0000000000..4808596cc5 --- /dev/null +++ b/pkg/integrations/cloudsmith/example_output_list_packages.json @@ -0,0 +1,36 @@ +{ + "data": { + "packages": [ + { + "description": "Example application container image", + "display_name": "example-app", + "format": "docker", + "is_quarantined": false, + "license": "MIT", + "policy_violated": false, + "repository": "example-repo", + "security_scan_status": "No Vulnerabilities Found", + "slug_perm": "example-pkg-id-1", + "stage_str": "Fully Synchronised", + "status_str": "Available", + "tags": {} + }, + { + "description": "Example application container image (previous release)", + "display_name": "example-app", + "format": "docker", + "is_quarantined": true, + "license": "MIT", + "policy_violated": true, + "repository": "example-repo", + "security_scan_status": "Scan Detected Vulnerabilities", + "slug_perm": "example-pkg-id-2", + "stage_str": "Fully Synchronised", + "status_str": "Available", + "tags": {} + } + ] + }, + "timestamp": "2026-01-15T14:35:00Z", + "type": "cloudsmith.packages.listed" +} diff --git a/pkg/integrations/cloudsmith/example_output_promote_package.json b/pkg/integrations/cloudsmith/example_output_promote_package.json new file mode 100644 index 0000000000..9f57118ada --- /dev/null +++ b/pkg/integrations/cloudsmith/example_output_promote_package.json @@ -0,0 +1,41 @@ +{ + "data": { + "cdn_url": "https://dl.cloudsmith.io/basic/example-owner/example-prod-repo/docker/example-app.manifest.json", + "checksum_md5": "00000000000000000000000000000000", + "checksum_sha1": "0000000000000000000000000000000000000000", + "checksum_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "checksum_sha512": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "display_name": "example-app", + "format": "docker", + "is_quarantined": false, + "is_sync_awaiting": false, + "is_sync_completed": true, + "is_sync_failed": false, + "is_sync_in_flight": false, + "is_sync_in_progress": false, + "name": "example-app", + "namespace": "example-owner", + "policy_violated": false, + "repository": "example-prod-repo", + "security_scan_status": "No Vulnerabilities Found", + "self_html_url": "https://cloudsmith.io/~example-owner/repos/example-prod-repo/packages/detail/docker/example-app/1.2.0/", + "self_url": "https://api.cloudsmith.io/v1/packages/example-owner/example-prod-repo/example-pkg-prod-id/", + "self_webapp_url": "https://app.cloudsmith.com/example-owner/r/example-prod-repo/docker/example-app/1.2.0/example-pkg-prod-id", + "size": 54525952, + "size_str": "52.0 MB", + "slug": "example-app-xyz9", + "slug_perm": "example-pkg-prod-id", + "stage": 9, + "stage_str": "Fully Synchronised", + "status": 2, + "status_str": "Available", + "sync_progress": 100, + "tags": {}, + "tags_immutable": {}, + "uploaded_at": "2026-01-15T14:30:00Z", + "uploader": "example-user", + "version": "1.2.0" + }, + "timestamp": "2026-01-15T15:00:00Z", + "type": "cloudsmith.package.promoted" +} diff --git a/pkg/integrations/cloudsmith/list_packages.go b/pkg/integrations/cloudsmith/list_packages.go new file mode 100644 index 0000000000..64a30d7e7a --- /dev/null +++ b/pkg/integrations/cloudsmith/list_packages.go @@ -0,0 +1,280 @@ +package cloudsmith + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type ListPackages struct{} + +type ListPackagesSpec struct { + Repository string `json:"repository" mapstructure:"repository"` + SyncStatus string `json:"syncStatus" mapstructure:"syncStatus"` + QuarantineStatus string `json:"quarantineStatus" mapstructure:"quarantineStatus"` + VulnerabilityStatus string `json:"vulnerabilityStatus" mapstructure:"vulnerabilityStatus"` +} + +// TrimmedPackage holds the subset of package fields emitted in the list result. +type TrimmedPackage struct { + Description string `json:"description"` + DisplayName string `json:"display_name"` + Format string `json:"format"` + IsQuarantined bool `json:"is_quarantined"` + License string `json:"license"` + PolicyViolated bool `json:"policy_violated"` + Repository string `json:"repository"` + SecurityScanStatus string `json:"security_scan_status"` + SlugPerm string `json:"slug_perm"` + StageStr string `json:"stage_str"` + StatusStr string `json:"status_str"` + Tags map[string]any `json:"tags"` +} + +// ListPackagesResult is the single payload emitted by ListPackages.Execute. +type ListPackagesResult struct { + Packages []TrimmedPackage `json:"packages"` +} + +func (l *ListPackages) Name() string { + return "cloudsmith.listPackages" +} + +func (l *ListPackages) Label() string { + return "List Packages" +} + +func (l *ListPackages) Description() string { + return "List packages in a Cloudsmith repository with optional filtering by sync status, quarantine, and vulnerability" +} + +func (l *ListPackages) Documentation() string { + return `The List Packages component fetches all packages in a Cloudsmith repository and optionally filters them by sync status, quarantine state, or vulnerability scan result. + +## Use Cases + +- **Release auditing**: List all fully synchronized packages before a release gate +- **Quarantine monitoring**: Enumerate quarantined packages for a security review workflow +- **Vulnerability triage**: Retrieve packages with detected vulnerabilities and route them to a remediation step +- **Inventory**: Collect a complete snapshot of packages in a repository for reporting + +## Configuration + +- **Repository** (required): The repository to list packages from, in the form ` + "`owner/repository`" + `. +- **Sync Status** (optional): Filter by package synchronization state (` + "`Any`" + `, ` + "`Fully Synchronised`" + `, ` + "`Awaiting Sync`" + `, ` + "`Sync Failed`" + `). +- **Quarantine Status** (optional): Filter by quarantine state (` + "`Any`" + `, ` + "`Quarantined`" + `, ` + "`Not Quarantined`" + `). +- **Vulnerability Status** (optional): Filter by security scan result (` + "`Any`" + `, ` + "`No Vulnerabilities`" + `, ` + "`Vulnerabilities Found`" + `). + +## Output + +Emits a single payload containing a ` + "`packages`" + ` array. Each entry includes: +- **display_name** / **format**: Package display name and format +- **status_str** / **stage_str**: Human-readable status and sync stage +- **is_quarantined** / **policy_violated**: Quarantine and policy state +- **description** / **license**: Package description and license +- **slug_perm** / **repository**: Permanent identifier and repository slug +- **tags**: Package tags` +} + +func (l *ListPackages) Icon() string { + return "list" +} + +func (l *ListPackages) Color() string { + return "gray" +} + +func (l *ListPackages) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (l *ListPackages) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "repository", + Label: "Repository", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The repository to list packages from", + Placeholder: "Select repository", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "repository", + UseNameAsValue: false, + }, + }, + }, + { + Name: "syncStatus", + Label: "Sync Status", + Type: configuration.FieldTypeSelect, + Required: false, + Description: "Filter packages by their synchronization state", + Default: "any", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Any", Value: "any"}, + {Label: "Fully Synchronised", Value: "fully_synchronised"}, + {Label: "Awaiting Sync", Value: "awaiting"}, + {Label: "Sync Failed", Value: "failed"}, + }, + }, + }, + }, + { + Name: "quarantineStatus", + Label: "Quarantine Status", + Type: configuration.FieldTypeSelect, + Required: false, + Description: "Filter packages by their quarantine state", + Default: "any", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Any", Value: "any"}, + {Label: "Quarantined", Value: "quarantined"}, + {Label: "Not Quarantined", Value: "not_quarantined"}, + }, + }, + }, + }, + { + Name: "vulnerabilityStatus", + Label: "Vulnerability Status", + Type: configuration.FieldTypeSelect, + Required: false, + Description: "Filter packages by their security scan result", + Default: "any", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Any", Value: "any"}, + {Label: "No Vulnerabilities", Value: "no_vulnerabilities"}, + {Label: "Vulnerabilities Found", Value: "vulnerabilities_found"}, + }, + }, + }, + }, + } +} + +func (l *ListPackages) Setup(ctx core.SetupContext) error { + spec := ListPackagesSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + if spec.Repository == "" { + return errors.New("repository is required") + } + + return resolveRepositoryMetadata(ctx, spec.Repository) +} + +func (l *ListPackages) Execute(ctx core.ExecutionContext) error { + spec := ListPackagesSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + owner, repo, err := parseRepositoryID(spec.Repository) + if err != nil { + return fmt.Errorf("invalid repository %q: %w", spec.Repository, err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + query := buildPackageQuery(spec) + packages, err := client.ListPackagesWithFilters(owner, repo, query) + if err != nil { + return fmt.Errorf("failed to list packages: %v", err) + } + + trimmed := make([]TrimmedPackage, len(packages)) + for i, pkg := range packages { + trimmed[i] = TrimmedPackage{ + Description: pkg.Description, + DisplayName: pkg.DisplayName, + Format: pkg.Format, + IsQuarantined: pkg.IsQuarantined, + License: pkg.License, + PolicyViolated: pkg.PolicyViolated, + Repository: pkg.Repository, + SecurityScanStatus: pkg.SecurityScanStatus, + SlugPerm: pkg.SlugPerm, + StageStr: pkg.StageStr, + StatusStr: pkg.StatusStr, + Tags: pkg.Tags, + } + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "cloudsmith.packages.listed", + []any{ListPackagesResult{Packages: trimmed}}, + ) +} + +// buildPackageQuery constructs a Cloudsmith Lucene-style query string from the spec filters. +func buildPackageQuery(spec ListPackagesSpec) string { + var parts []string + + switch spec.SyncStatus { + case "fully_synchronised": + parts = append(parts, "is_sync_completed:true") + case "awaiting": + parts = append(parts, "is_sync_awaiting:true") + case "failed": + parts = append(parts, "is_sync_failed:true") + } + + switch spec.QuarantineStatus { + case "quarantined": + parts = append(parts, "is_quarantined:true") + case "not_quarantined": + parts = append(parts, "is_quarantined:false") + } + + switch spec.VulnerabilityStatus { + case "no_vulnerabilities": + parts = append(parts, "security_scan_status:\"No Vulnerabilities Found\"") + case "vulnerabilities_found": + parts = append(parts, "security_scan_status:\"Scan Detected Vulnerabilities\"") + } + + return strings.Join(parts, " AND ") +} + +func (l *ListPackages) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (l *ListPackages) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (l *ListPackages) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (l *ListPackages) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (l *ListPackages) Hooks() []core.Hook { + return []core.Hook{} +} + +func (l *ListPackages) HandleHook(ctx core.ActionHookContext) error { + return nil +} diff --git a/pkg/integrations/cloudsmith/list_packages_test.go b/pkg/integrations/cloudsmith/list_packages_test.go new file mode 100644 index 0000000000..af7c4d20ff --- /dev/null +++ b/pkg/integrations/cloudsmith/list_packages_test.go @@ -0,0 +1,191 @@ +package cloudsmith + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__ListPackages__Setup(t *testing.T) { + component := &ListPackages{} + + t.Run("missing repository returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{}, + Metadata: &contexts.MetadataContext{}, + }) + + require.ErrorContains(t, err, "repository is required") + }) + + t.Run("empty repository returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"repository": ""}, + Metadata: &contexts.MetadataContext{}, + }) + + require.ErrorContains(t, err, "repository is required") + }) + + t.Run("expression repository is stored without API call", func(t *testing.T) { + metadataCtx := &contexts.MetadataContext{} + + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "repository": "{{ $.trigger.data.repository }}", + }, + Metadata: metadataCtx, + }) + + require.NoError(t, err) + metadata, ok := metadataCtx.Metadata.(RepositoryNodeMetadata) + require.True(t, ok) + assert.Equal(t, "{{ $.trigger.data.repository }}", metadata.RepositoryName) + }) + + t.Run("valid repository resolves metadata", func(t *testing.T) { + metadataCtx := &contexts.MetadataContext{} + + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "repository": "acme/production", + }, + HTTP: &contexts.HTTPContext{ + Responses: []*http.Response{ + okResponse(`{"name":"Production","slug":"production","namespace":"acme"}`), + }, + }, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "test-key"}, + }, + Metadata: metadataCtx, + }) + + require.NoError(t, err) + metadata, ok := metadataCtx.Metadata.(RepositoryNodeMetadata) + require.True(t, ok) + assert.Equal(t, "Production", metadata.RepositoryName) + }) +} + +func Test__ListPackages__Execute(t *testing.T) { + component := &ListPackages{} + + pkg1JSON := `{"slug":"my-package-1-0-0","slug_perm":"perm1","name":"my-package","version":"1.0.0","format":"docker","status":2,"status_str":"Available","stage":9,"stage_str":"Fully Synchronised","is_quarantined":false,"security_scan_status":"No Vulnerabilities Found","size":52428800,"size_str":"50.0 MB","uploaded_at":"2026-01-01T10:00:00Z"}` + pkg2JSON := `{"slug":"my-package-1-1-0","slug_perm":"perm2","name":"my-package","version":"1.1.0","format":"docker","status":2,"status_str":"Available","stage":9,"stage_str":"Fully Synchronised","is_quarantined":false,"security_scan_status":"No Vulnerabilities Found","size":54525952,"size_str":"52.0 MB","uploaded_at":"2026-01-15T14:30:00Z"}` + packagesJSON := "[" + pkg1JSON + "," + pkg2JSON + "]" + + t.Run("successful list emits all packages", func(t *testing.T) { + executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "repository": "acme/production", + }, + HTTP: &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(packagesJSON)), + }, + }, + }, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "test-key"}, + }, + ExecutionState: executionState, + }) + + require.NoError(t, err) + assert.True(t, executionState.Passed) + assert.Equal(t, "default", executionState.Channel) + assert.Equal(t, "cloudsmith.packages.listed", executionState.Type) + require.Len(t, executionState.Payloads, 1) + wrapped, ok := executionState.Payloads[0].(map[string]any) + require.True(t, ok) + result, ok := wrapped["data"].(ListPackagesResult) + require.True(t, ok) + assert.Len(t, result.Packages, 2) + }) + + t.Run("empty repository returns error", func(t *testing.T) { + executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"repository": "no-namespace"}, + HTTP: &contexts.HTTPContext{}, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}}, + ExecutionState: executionState, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid repository") + }) + + t.Run("repository not found returns error", func(t *testing.T) { + executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"repository": "acme/missing"}, + HTTP: &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader(`{"detail":"Not found."}`)), + }, + }, + }, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}}, + ExecutionState: executionState, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to list packages") + }) + + t.Run("fully_synchronised filter builds correct query", func(t *testing.T) { + spec := ListPackagesSpec{SyncStatus: "fully_synchronised"} + assert.Equal(t, "is_sync_completed:true", buildPackageQuery(spec)) + }) + + t.Run("quarantined filter builds correct query", func(t *testing.T) { + spec := ListPackagesSpec{QuarantineStatus: "quarantined"} + assert.Equal(t, "is_quarantined:true", buildPackageQuery(spec)) + }) + + t.Run("combined filters build AND query", func(t *testing.T) { + spec := ListPackagesSpec{ + SyncStatus: "fully_synchronised", + QuarantineStatus: "not_quarantined", + VulnerabilityStatus: "no_vulnerabilities", + } + query := buildPackageQuery(spec) + assert.Contains(t, query, "is_sync_completed:true") + assert.Contains(t, query, "is_quarantined:false") + assert.Contains(t, query, "No Vulnerabilities Found") + assert.Contains(t, query, " AND ") + }) + + t.Run("any filters produce empty query", func(t *testing.T) { + spec := ListPackagesSpec{ + SyncStatus: "any", + QuarantineStatus: "any", + VulnerabilityStatus: "any", + } + assert.Equal(t, "", buildPackageQuery(spec)) + }) +} + +func Test__ListPackages__ExampleOutput(t *testing.T) { + output := (&ListPackages{}).ExampleOutput() + require.NotNil(t, output) + assert.Equal(t, "cloudsmith.packages.listed", output["type"]) + assert.NotEmpty(t, output["timestamp"]) + assert.NotNil(t, output["data"]) +} diff --git a/pkg/integrations/cloudsmith/promote_package.go b/pkg/integrations/cloudsmith/promote_package.go new file mode 100644 index 0000000000..51badd5ba3 --- /dev/null +++ b/pkg/integrations/cloudsmith/promote_package.go @@ -0,0 +1,246 @@ +package cloudsmith + +import ( + "errors" + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + PromoteModeMove = "move" + PromoteModeCopy = "copy" +) + +type PromotePackage struct{} + +type PromotePackageSpec struct { + SourceRepository string `json:"sourceRepository" mapstructure:"sourceRepository"` + Package string `json:"package" mapstructure:"package"` + DestinationRepository string `json:"destinationRepository" mapstructure:"destinationRepository"` + Mode string `json:"mode" mapstructure:"mode"` +} + +func (p *PromotePackage) Name() string { + return "cloudsmith.promotePackage" +} + +func (p *PromotePackage) Label() string { + return "Promote Package" +} + +func (p *PromotePackage) Description() string { + return "Copy or move a package from one Cloudsmith repository to another" +} + +func (p *PromotePackage) Documentation() string { + return `The Promote Package component copies or moves a package from a source repository to a destination repository within the same Cloudsmith namespace. + +## Use Cases + +- **Promotion pipelines**: Move a package from staging to production after all checks pass +- **Multi-environment distribution**: Copy a package to multiple target repositories simultaneously +- **Artifact archiving**: Move packages from active repositories to archive repositories +- **Release management**: Promote a vetted version from a dev channel to a release channel + +## Configuration + +- **Source Repository** (required): The repository that currently holds the package, in the form ` + "`owner/repository`" + `. +- **Package** (required): The unique package identifier (` + "`slug_perm`" + `). Supports expressions — use ` + "`{{ $['On Package Created'].data.slug_perm }}`" + ` to reference an upstream trigger. +- **Destination Repository** (required): The target repository to promote the package into, in the form ` + "`owner/repository`" + `. +- **Mode** (required): Whether to ` + "`copy`" + ` (keep the original) or ` + "`move`" + ` (remove from source). + +## Output + +Returns the promoted package as it appears in the destination repository, including: +- **name** / **version**: Package name and version +- **format**: Package format (e.g., ` + "`docker`" + `, ` + "`python`" + `, ` + "`debian`" + `) +- **repository** / **namespace**: Where the package now lives +- **self_webapp_url**: URL to the promoted package in the Cloudsmith web app +- **slug_perm**: Permanent identifier of the package in the destination +- **uploaded_at**: Original upload timestamp` +} + +func (p *PromotePackage) Icon() string { + return "copy" +} + +func (p *PromotePackage) Color() string { + return "blue" +} + +func (p *PromotePackage) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (p *PromotePackage) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "sourceRepository", + Label: "Source Repository", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The repository currently holding the package", + Placeholder: "Select source repository", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "repository", + UseNameAsValue: false, + }, + }, + }, + { + Name: "package", + Label: "Package", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The package to promote", + Placeholder: "Select package", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "package", + UseNameAsValue: false, + Parameters: []configuration.ParameterRef{ + { + Name: "repository", + ValueFrom: &configuration.ParameterValueFrom{Field: "sourceRepository"}, + }, + }, + }, + }, + }, + { + Name: "destinationRepository", + Label: "Destination Repository", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The repository to promote the package into", + Placeholder: "Select destination repository", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "repository", + UseNameAsValue: false, + }, + }, + }, + { + Name: "mode", + Label: "Mode", + Type: configuration.FieldTypeSelect, + Required: true, + Description: "Copy keeps the original; Move removes it from the source", + Default: PromoteModeCopy, + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Copy", Value: PromoteModeCopy, Description: "Copy the package; the original remains in the source repository"}, + {Label: "Move", Value: PromoteModeMove, Description: "Move the package; it is removed from the source repository"}, + }, + }, + }, + }, + } +} + +func (p *PromotePackage) Setup(ctx core.SetupContext) error { + spec := PromotePackageSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + if spec.SourceRepository == "" { + return errors.New("sourceRepository is required") + } + + if spec.Package == "" { + return errors.New("package is required") + } + + if spec.DestinationRepository == "" { + return errors.New("destinationRepository is required") + } + + if spec.Mode != PromoteModeCopy && spec.Mode != PromoteModeMove { + return fmt.Errorf("mode must be %q or %q, got %q", PromoteModeCopy, PromoteModeMove, spec.Mode) + } + + return resolvePackageMetadata(ctx, spec.SourceRepository, spec.Package) +} + +func (p *PromotePackage) Execute(ctx core.ExecutionContext) error { + spec := PromotePackageSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + owner, sourceRepo, err := parseRepositoryID(spec.SourceRepository) + if err != nil { + return fmt.Errorf("invalid sourceRepository %q: %w", spec.SourceRepository, err) + } + + destOwner, destRepo, err := parseRepositoryID(spec.DestinationRepository) + if err != nil { + return fmt.Errorf("invalid destinationRepository %q: %w", spec.DestinationRepository, err) + } + + if destOwner != owner { + return fmt.Errorf("cross-namespace promotion is not supported: source owner %q and destination owner %q must match", owner, destOwner) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + var pkg *Package + switch spec.Mode { + case PromoteModeMove: + pkg, err = client.MovePackage(owner, sourceRepo, spec.Package, destRepo) + if err != nil { + return fmt.Errorf("failed to move package: %v", err) + } + default: + pkg, err = client.CopyPackage(owner, sourceRepo, spec.Package, destRepo) + if err != nil { + return fmt.Errorf("failed to copy package: %v", err) + } + } + + if pkg == nil { + return errors.New("promote returned empty response") + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "cloudsmith.package.promoted", + []any{pkg}, + ) +} + +func (p *PromotePackage) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (p *PromotePackage) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (p *PromotePackage) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (p *PromotePackage) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (p *PromotePackage) Hooks() []core.Hook { + return []core.Hook{} +} + +func (p *PromotePackage) HandleHook(ctx core.ActionHookContext) error { + return nil +} diff --git a/pkg/integrations/cloudsmith/promote_package_test.go b/pkg/integrations/cloudsmith/promote_package_test.go new file mode 100644 index 0000000000..fda1b4a486 --- /dev/null +++ b/pkg/integrations/cloudsmith/promote_package_test.go @@ -0,0 +1,305 @@ +package cloudsmith + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__PromotePackage__Setup(t *testing.T) { + component := &PromotePackage{} + + t.Run("missing sourceRepository returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "package": "perm123", + "destinationRepository": "acme/production", + }, + Metadata: &contexts.MetadataContext{}, + }) + + require.ErrorContains(t, err, "sourceRepository is required") + }) + + t.Run("missing package returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "sourceRepository": "acme/staging", + "destinationRepository": "acme/production", + }, + Metadata: &contexts.MetadataContext{}, + HTTP: &contexts.HTTPContext{ + Responses: []*http.Response{ + okResponse(`{"name":"Staging","slug":"staging","namespace":"acme"}`), + }, + }, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "test-key"}, + }, + }) + + require.ErrorContains(t, err, "package is required") + }) + + t.Run("missing destinationRepository returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "sourceRepository": "acme/staging", + "package": "perm123", + }, + Metadata: &contexts.MetadataContext{}, + HTTP: &contexts.HTTPContext{ + Responses: []*http.Response{ + okResponse(`{"name":"Staging","slug":"staging","namespace":"acme"}`), + okResponse(`{"slug":"my-package-1-0-0","slug_perm":"perm123","name":"my-package","version":"1.0.0"}`), + }, + }, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "test-key"}, + }, + }) + + require.ErrorContains(t, err, "destinationRepository is required") + }) + + t.Run("missing mode returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "sourceRepository": "acme/staging", + "package": "perm123", + "destinationRepository": "acme/production", + }, + HTTP: &contexts.HTTPContext{}, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}}, + }) + + require.ErrorContains(t, err, "mode must be") + }) + + t.Run("invalid mode returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "sourceRepository": "acme/staging", + "package": "perm123", + "destinationRepository": "acme/production", + "mode": "promote", + }, + HTTP: &contexts.HTTPContext{}, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}}, + }) + + require.ErrorContains(t, err, "mode must be") + }) + + t.Run("valid configuration resolves package metadata", func(t *testing.T) { + metadataCtx := &contexts.MetadataContext{} + + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "sourceRepository": "acme/staging", + "package": "perm123", + "destinationRepository": "acme/production", + "mode": PromoteModeCopy, + }, + HTTP: &contexts.HTTPContext{ + Responses: []*http.Response{ + okResponse(`{"name":"Staging","slug":"staging","namespace":"acme"}`), + okResponse(`{"slug":"my-package-1-0-0","slug_perm":"perm123","name":"my-package","version":"1.0.0"}`), + }, + }, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "test-key"}, + }, + Metadata: metadataCtx, + }) + + require.NoError(t, err) + metadata, ok := metadataCtx.Metadata.(PackageNodeMetadata) + require.True(t, ok) + assert.Equal(t, "Staging", metadata.RepositoryName) + assert.Equal(t, "perm123", metadata.PackageID) + }) +} + +func Test__PromotePackage__Execute(t *testing.T) { + component := &PromotePackage{} + + promotedPackageJSON := `{ + "slug": "my-package-1-0-0", + "slug_perm": "perm123", + "name": "my-package", + "version": "1.0.0", + "format": "docker", + "status": 2, + "status_str": "Available", + "repository": "production", + "namespace": "acme", + "size": 52428800, + "size_str": "50.0 MB", + "self_webapp_url": "https://app.cloudsmith.com/acme/r/production/docker/my-package/1.0.0/perm123" + }` + + t.Run("copy mode emits promoted package data", func(t *testing.T) { + executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "sourceRepository": "acme/staging", + "package": "perm123", + "destinationRepository": "acme/production", + "mode": PromoteModeCopy, + }, + HTTP: &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(promotedPackageJSON)), + }, + }, + }, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "test-key"}, + }, + ExecutionState: executionState, + }) + + require.NoError(t, err) + assert.True(t, executionState.Passed) + assert.Equal(t, "default", executionState.Channel) + assert.Equal(t, "cloudsmith.package.promoted", executionState.Type) + require.Len(t, executionState.Payloads, 1) + + wrapped, ok := executionState.Payloads[0].(map[string]any) + require.True(t, ok) + pkg, ok := wrapped["data"].(*Package) + require.True(t, ok) + assert.Equal(t, "my-package", pkg.Name) + assert.Equal(t, "1.0.0", pkg.Version) + assert.Equal(t, "production", pkg.Repository) + }) + + t.Run("move mode emits promoted package data", func(t *testing.T) { + executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "sourceRepository": "acme/staging", + "package": "perm123", + "destinationRepository": "acme/production", + "mode": PromoteModeMove, + }, + HTTP: &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(promotedPackageJSON)), + }, + }, + }, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "test-key"}, + }, + ExecutionState: executionState, + }) + + require.NoError(t, err) + assert.True(t, executionState.Passed) + assert.Equal(t, "cloudsmith.package.promoted", executionState.Type) + require.Len(t, executionState.Payloads, 1) + }) + + t.Run("invalid sourceRepository format returns error", func(t *testing.T) { + executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "sourceRepository": "invalid", + "package": "perm123", + "destinationRepository": "acme/production", + "mode": PromoteModeCopy, + }, + HTTP: &contexts.HTTPContext{}, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}}, + ExecutionState: executionState, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid sourceRepository") + }) + + t.Run("invalid destinationRepository format returns error", func(t *testing.T) { + executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "sourceRepository": "acme/staging", + "package": "perm123", + "destinationRepository": "invalid", + "mode": PromoteModeCopy, + }, + HTTP: &contexts.HTTPContext{}, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}}, + ExecutionState: executionState, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid destinationRepository") + }) + + t.Run("cross-namespace destination returns error", func(t *testing.T) { + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "sourceRepository": "acme/staging", + "package": "perm123", + "destinationRepository": "other-owner/production", + "mode": PromoteModeCopy, + }, + HTTP: &contexts.HTTPContext{}, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}}, + ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "cross-namespace promotion is not supported") + }) + + t.Run("API error returns error", func(t *testing.T) { + executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "sourceRepository": "acme/staging", + "package": "perm123", + "destinationRepository": "acme/production", + "mode": PromoteModeCopy, + }, + HTTP: &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`{"detail":"Permission denied."}`)), + }, + }, + }, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}}, + ExecutionState: executionState, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to copy package") + }) +} + +func Test__PromotePackage__ExampleOutput(t *testing.T) { + output := (&PromotePackage{}).ExampleOutput() + require.NotNil(t, output) + assert.Equal(t, "cloudsmith.package.promoted", output["type"]) + assert.NotEmpty(t, output["timestamp"]) + assert.NotNil(t, output["data"]) +} diff --git a/web_src/src/pages/app/mappers/cloudsmith/index.ts b/web_src/src/pages/app/mappers/cloudsmith/index.ts index d27f27e6c0..98efff58db 100644 --- a/web_src/src/pages/app/mappers/cloudsmith/index.ts +++ b/web_src/src/pages/app/mappers/cloudsmith/index.ts @@ -2,6 +2,8 @@ import type { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from ". import { buildActionStateRegistry } from "../utils"; import { getRepositoryMapper } from "./get_repository"; import { getPackageMapper } from "./get_package"; +import { listPackagesMapper } from "./list_packages"; +import { promotePackageMapper, promotePackageEventStateRegistry } from "./promote_package"; import { onSecurityScanCompletedTriggerRenderer } from "./on_security_scan_completed"; import { onPackageCreatedTriggerRenderer } from "./on_package_created"; import { resyncPackageMapper } from "./resync_package"; @@ -14,6 +16,8 @@ export const componentMappers: Record = { resyncPackage: resyncPackageMapper, tagPackage: tagPackageMapper, deletePackage: deletePackageMapper, + listPackages: listPackagesMapper, + promotePackage: promotePackageMapper, }; export const triggerRenderers: Record = { @@ -24,6 +28,8 @@ export const triggerRenderers: Record = { export const eventStateRegistry: Record = { getRepository: buildActionStateRegistry("fetched"), getPackage: buildActionStateRegistry("fetched"), + listPackages: buildActionStateRegistry("completed"), + promotePackage: promotePackageEventStateRegistry, onSecurityScanCompleted: buildActionStateRegistry("triggered"), onPackageCreated: buildActionStateRegistry("triggered"), resyncPackage: buildActionStateRegistry("resynced"), diff --git a/web_src/src/pages/app/mappers/cloudsmith/list_packages.spec.ts b/web_src/src/pages/app/mappers/cloudsmith/list_packages.spec.ts new file mode 100644 index 0000000000..9d4eeed2c8 --- /dev/null +++ b/web_src/src/pages/app/mappers/cloudsmith/list_packages.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { listPackagesMapper } from "./list_packages"; +import { buildDetailsCtx } from "./test_helpers"; +import type { TrimmedPackageData } from "./types"; + +describe("listPackagesMapper.getExecutionDetails", () => { + it("does not throw when outputs is undefined", () => { + const ctx = buildDetailsCtx({ execution: { outputs: undefined } }); + expect(() => listPackagesMapper.getExecutionDetails(ctx)).not.toThrow(); + }); + + it("does not throw when default array is empty", () => { + const ctx = buildDetailsCtx({ execution: { outputs: { default: [] } } }); + expect(() => listPackagesMapper.getExecutionDetails(ctx)).not.toThrow(); + }); + + it("returns Executed At when packages are missing", () => { + const ctx = buildDetailsCtx({ + execution: { outputs: { default: [buildListPackagesOutput(undefined)] } }, + }); + const details = listPackagesMapper.getExecutionDetails(ctx); + expect(details["Executed At"]).toBeDefined(); + expect(details["Packages Found"]).toBeUndefined(); + }); + + it("shows total packages found count", () => { + const ctx = buildDetailsCtx({ + execution: { + outputs: { + default: [buildListPackagesOutput([buildTrimmedPackage(), buildTrimmedPackage()])], + }, + }, + }); + const details = listPackagesMapper.getExecutionDetails(ctx); + expect(details["Packages Found"]).toBe("2"); + }); + + it("shows quarantined package count", () => { + const ctx = buildDetailsCtx({ + execution: { + outputs: { + default: [ + buildListPackagesOutput([ + buildTrimmedPackage({ is_quarantined: true }), + buildTrimmedPackage({ is_quarantined: false }), + buildTrimmedPackage({ is_quarantined: true }), + ]), + ], + }, + }, + }); + const details = listPackagesMapper.getExecutionDetails(ctx); + expect(details["Quarantined"]).toBe("2"); + }); + + it("shows vulnerable (security_scan_status) package count", () => { + const ctx = buildDetailsCtx({ + execution: { + outputs: { + default: [ + buildListPackagesOutput([ + buildTrimmedPackage({ security_scan_status: "Scan Detected Vulnerabilities" }), + buildTrimmedPackage({ security_scan_status: "No Vulnerabilities Found" }), + buildTrimmedPackage({ security_scan_status: "Scan Detected Vulnerabilities" }), + ]), + ], + }, + }, + }); + const details = listPackagesMapper.getExecutionDetails(ctx); + expect(details["Vulnerable"]).toBe("2"); + }); + + it("shows zero quarantined and vulnerable when all packages are clean", () => { + const ctx = buildDetailsCtx({ + execution: { + outputs: { + default: [buildListPackagesOutput([buildTrimmedPackage(), buildTrimmedPackage()])], + }, + }, + }); + const details = listPackagesMapper.getExecutionDetails(ctx); + expect(details["Quarantined"]).toBe("0"); + expect(details["Vulnerable"]).toBe("0"); + }); + + it("does not include Format, Status, Security Scan, or Repository URL in details", () => { + const ctx = buildDetailsCtx({ + execution: { + outputs: { + default: [buildListPackagesOutput([buildTrimmedPackage({ format: "docker", status_str: "Available" })])], + }, + }, + }); + const details = listPackagesMapper.getExecutionDetails(ctx); + expect(details["Format"]).toBeUndefined(); + expect(details["Status"]).toBeUndefined(); + expect(details["Security Scan"]).toBeUndefined(); + expect(details["Repository URL"]).toBeUndefined(); + }); +}); + +function buildTrimmedPackage(overrides?: Partial): TrimmedPackageData { + return { + display_name: "my-package", + format: "docker", + is_quarantined: false, + policy_violated: false, + repository: "production", + security_scan_status: "No Vulnerabilities Found", + slug_perm: "perm123abc", + stage_str: "Fully Synchronised", + status_str: "Available", + ...overrides, + }; +} + +function buildListPackagesOutput(packages: TrimmedPackageData[] | undefined) { + return { + type: "cloudsmith.packages.listed", + timestamp: new Date().toISOString(), + data: packages !== undefined ? { packages } : undefined, + }; +} diff --git a/web_src/src/pages/app/mappers/cloudsmith/list_packages.ts b/web_src/src/pages/app/mappers/cloudsmith/list_packages.ts new file mode 100644 index 0000000000..6b751609a8 --- /dev/null +++ b/web_src/src/pages/app/mappers/cloudsmith/list_packages.ts @@ -0,0 +1,98 @@ +import type { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import type React from "react"; +import { getBackgroundColorClass } from "@/lib/colors"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import type { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import type { MetadataItem } from "@/ui/metadataList"; +import cloudsmithIcon from "@/assets/icons/integrations/cloudsmith.svg"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import type { ListPackagesConfiguration, ListPackagesData, RepositoryNodeMetadata } from "./types"; + +export const listPackagesMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name ?? "cloudsmith"; + + return { + iconSrc: cloudsmithIcon, + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + title: context.node.name || context.componentDefinition.label || "Unnamed component", + eventSections: lastExecution ? buildEventSections(context.nodes, lastExecution, componentName) : undefined, + metadata: buildMetadata(context.node), + includeEmptyState: !lastExecution, + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const details: Record = {}; + + if (context.execution.createdAt) { + details["Executed At"] = new Date(context.execution.createdAt).toLocaleString(); + } + + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const firstPayload = outputs?.default?.[0]; + const result = firstPayload?.data as ListPackagesData | undefined; + const packages = result?.packages; + if (!packages) return details; + + details["Packages Found"] = String(packages.length); + + const quarantinedCount = packages.filter((p) => p.is_quarantined).length; + details["Quarantined"] = String(quarantinedCount); + + const vulnerableCount = packages.filter((p) => p.security_scan_status === "Scan Detected Vulnerabilities").length; + details["Vulnerable"] = String(vulnerableCount); + + return details; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + if (!context.execution.createdAt) return ""; + return renderTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function buildMetadata(node: NodeInfo): MetadataItem[] { + const items: MetadataItem[] = []; + const nodeMetadata = node.metadata as RepositoryNodeMetadata | undefined; + const configuration = node.configuration as ListPackagesConfiguration | undefined; + + if (nodeMetadata?.repositoryName) { + items.push({ icon: "package", label: nodeMetadata.repositoryName }); + } else if (configuration?.repository) { + items.push({ icon: "package", label: configuration.repository }); + } + + return items; +} + +function buildEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + if (!execution.rootEvent || !execution.createdAt || !execution.rootEvent.id) { + return []; + } + + const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName ?? ""); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent }); + + return [ + { + receivedAt: new Date(execution.createdAt), + eventTitle: title, + eventSubtitle: renderTimeAgo(new Date(execution.createdAt)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent.id, + }, + ]; +} diff --git a/web_src/src/pages/app/mappers/cloudsmith/promote_package.spec.ts b/web_src/src/pages/app/mappers/cloudsmith/promote_package.spec.ts new file mode 100644 index 0000000000..185eec1203 --- /dev/null +++ b/web_src/src/pages/app/mappers/cloudsmith/promote_package.spec.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { promotePackageMapper, promotePackageEventStateRegistry } from "./promote_package"; +import { buildDetailsCtx, buildPackageData, buildPackageOutput, buildNode } from "./test_helpers"; + +describe("promotePackageMapper.getExecutionDetails", () => { + it("does not throw when outputs is undefined", () => { + const ctx = buildDetailsCtx({ execution: { outputs: undefined } }); + expect(() => promotePackageMapper.getExecutionDetails(ctx)).not.toThrow(); + }); + + it("does not throw when default array is empty", () => { + const ctx = buildDetailsCtx({ execution: { outputs: { default: [] } } }); + expect(() => promotePackageMapper.getExecutionDetails(ctx)).not.toThrow(); + }); + + it("returns Executed At without package fields when output data is missing", () => { + const ctx = buildDetailsCtx({ + execution: { outputs: { default: [buildPromoteOutput(undefined)] } }, + }); + const details = promotePackageMapper.getExecutionDetails(ctx); + expect(details["Executed At"]).toBeDefined(); + expect(details["Package"]).toBeUndefined(); + }); + + it("extracts key promoted package fields", () => { + const pkg = buildPackageData({ + name: "my-package", + version: "1.2.0", + repository: "production", + }); + const ctx = buildDetailsCtx({ + execution: { outputs: { default: [buildPromoteOutput(pkg)] } }, + }); + const details = promotePackageMapper.getExecutionDetails(ctx); + expect(details["Executed At"]).toBeDefined(); + expect(details["Package"]).toBe("my-package"); + expect(details["Version"]).toBe("1.2.0"); + expect(details["Destination"]).toBe("production"); + }); + + it("does not include Status, URL, Size, or Security Scan in details", () => { + const ctx = buildDetailsCtx({ + execution: { outputs: { default: [buildPromoteOutput(buildPackageData())] } }, + }); + const details = promotePackageMapper.getExecutionDetails(ctx); + expect(details["Status"]).toBeUndefined(); + expect(details["URL"]).toBeUndefined(); + expect(details["Size"]).toBeUndefined(); + expect(details["Security Scan"]).toBeUndefined(); + }); +}); + +describe("promotePackageEventStateRegistry.getState", () => { + const { getState } = promotePackageEventStateRegistry; + + it("returns copied when mode is copy (default)", () => { + const execution = buildExecution({ + result: "RESULT_PASSED", + state: "STATE_FINISHED", + configuration: { mode: "copy" }, + }); + expect(getState(execution)).toBe("copied"); + }); + + it("returns copied when mode is not set", () => { + const execution = buildExecution({ result: "RESULT_PASSED", state: "STATE_FINISHED", configuration: {} }); + expect(getState(execution)).toBe("copied"); + }); + + it("returns moved when mode is move", () => { + const execution = buildExecution({ + result: "RESULT_PASSED", + state: "STATE_FINISHED", + configuration: { mode: "move" }, + }); + expect(getState(execution)).toBe("moved"); + }); + + it("returns failed for a failed execution", () => { + const execution = buildExecution({ + result: "RESULT_FAILED", + state: "STATE_FINISHED", + configuration: { mode: "move" }, + }); + expect(getState(execution)).toBe("failed"); + }); + + it("has stateMap entries for both copied and moved", () => { + expect(promotePackageEventStateRegistry.stateMap["copied"]).toBeDefined(); + expect(promotePackageEventStateRegistry.stateMap["moved"]).toBeDefined(); + }); +}); + +describe("promotePackageMapper metadata", () => { + it("shows destination repository from configuration", () => { + const labels = getMetadataLabels({ destinationRepository: "acme/production", mode: "copy" }); + expect(labels).toContain("acme/production"); + }); + + it("shows action label from configuration mode", () => { + const labels = getMetadataLabels({ destinationRepository: "acme/production", mode: "move" }); + expect(labels).toContain("Move"); + }); + + it("shows Copy for copy mode", () => { + const labels = getMetadataLabels({ destinationRepository: "acme/production", mode: "copy" }); + expect(labels).toContain("Copy"); + }); +}); + +function getMetadataLabels(configOverrides: Record): (string | unknown)[] { + const node = buildNode({ + configuration: { + sourceRepository: "acme/staging", + package: "perm123", + ...configOverrides, + }, + metadata: {}, + }); + const metadata = + promotePackageMapper.props({ + nodes: [node], + node, + componentDefinition: { + name: "cloudsmith.promotePackage", + label: "Promote Package", + description: "", + icon: "copy", + color: "blue", + }, + lastExecutions: [], + currentUser: undefined, + actions: { invokeNodeExecutionHook: async () => {} }, + }).metadata ?? []; + return metadata.map((m) => m.label); +} + +function buildPromoteOutput(data: unknown) { + return buildPackageOutput(data, "cloudsmith.package.promoted"); +} + +function buildExecution(overrides: { result: string; state: string; configuration: unknown }) { + return { + id: "exec-1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + state: overrides.state as never, + result: overrides.result as never, + resultReason: "RESULT_REASON_OK" as never, + resultMessage: "", + metadata: {}, + configuration: overrides.configuration, + rootEvent: undefined as never, + }; +} diff --git a/web_src/src/pages/app/mappers/cloudsmith/promote_package.ts b/web_src/src/pages/app/mappers/cloudsmith/promote_package.ts new file mode 100644 index 0000000000..11ce6540a9 --- /dev/null +++ b/web_src/src/pages/app/mappers/cloudsmith/promote_package.ts @@ -0,0 +1,123 @@ +import type { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import { DEFAULT_EVENT_STATE_MAP } from "@/ui/componentBase"; +import type React from "react"; +import { getBackgroundColorClass } from "@/lib/colors"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import type { + ComponentBaseContext, + ComponentBaseMapper, + EventStateRegistry, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import { defaultStateFunction } from "../stateRegistry"; +import type { MetadataItem } from "@/ui/metadataList"; +import cloudsmithIcon from "@/assets/icons/integrations/cloudsmith.svg"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import type { PackageData, PackageNodeMetadata, PromotePackageConfiguration } from "./types"; + +export const promotePackageEventStateRegistry: EventStateRegistry = { + stateMap: { + ...DEFAULT_EVENT_STATE_MAP, + copied: DEFAULT_EVENT_STATE_MAP.success, + moved: DEFAULT_EVENT_STATE_MAP.success, + }, + getState: (execution) => { + const state = defaultStateFunction(execution); + if (state !== "success") return state; + const config = execution.configuration as PromotePackageConfiguration | undefined; + return config?.mode === "move" ? "moved" : "copied"; + }, +}; + +export const promotePackageMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name ?? "cloudsmith"; + + return { + iconSrc: cloudsmithIcon, + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + title: context.node.name || context.componentDefinition.label || "Unnamed component", + eventSections: lastExecution ? buildEventSections(context.nodes, lastExecution, componentName) : undefined, + metadata: buildMetadata(context.node), + includeEmptyState: !lastExecution, + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const details: Record = {}; + + if (context.execution.createdAt) { + details["Executed At"] = new Date(context.execution.createdAt).toLocaleString(); + } + + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const pkg = outputs?.default?.[0]?.data as PackageData | undefined; + if (!pkg) return details; + + if (pkg.name) details["Package"] = pkg.name; + if (pkg.version) details["Version"] = pkg.version; + if (pkg.repository) details["Destination"] = pkg.repository; + + return details; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + if (!context.execution.createdAt) return ""; + return renderTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function buildMetadata(node: NodeInfo): MetadataItem[] { + const items: MetadataItem[] = []; + const nodeMetadata = node.metadata as PackageNodeMetadata | undefined; + const configuration = node.configuration as PromotePackageConfiguration | undefined; + + if (nodeMetadata?.repositoryName) { + items.push({ icon: "package", label: nodeMetadata.repositoryName }); + } else if (configuration?.sourceRepository) { + items.push({ icon: "package", label: configuration.sourceRepository }); + } + + if (nodeMetadata?.packageName) { + items.push({ icon: "archive", label: nodeMetadata.packageName }); + } else if (configuration?.package) { + items.push({ icon: "archive", label: configuration.package }); + } + + if (configuration?.destinationRepository) { + items.push({ icon: "arrow-right", label: configuration.destinationRepository }); + } + + if (configuration?.mode) { + items.push({ icon: "copy", label: configuration.mode === "move" ? "Move" : "Copy" }); + } + + return items; +} + +function buildEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + if (!execution.rootEvent || !execution.createdAt || !execution.rootEvent.id) { + return []; + } + + const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName ?? ""); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent }); + + return [ + { + receivedAt: new Date(execution.createdAt), + eventTitle: title, + eventSubtitle: renderTimeAgo(new Date(execution.createdAt)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent.id, + }, + ]; +} diff --git a/web_src/src/pages/app/mappers/cloudsmith/types.ts b/web_src/src/pages/app/mappers/cloudsmith/types.ts index 281d810c03..a67227c3bc 100644 --- a/web_src/src/pages/app/mappers/cloudsmith/types.ts +++ b/web_src/src/pages/app/mappers/cloudsmith/types.ts @@ -138,3 +138,52 @@ export interface PackageOperationResult { package?: string; data?: PackageData; } + +// List Packages + +export interface ListPackagesConfiguration { + repository?: string; + syncStatus?: string; + quarantineStatus?: string; + vulnerabilityStatus?: string; +} + +export interface TrimmedPackageData { + description?: string; + display_name?: string; + format?: string; + is_quarantined?: boolean; + license?: string; + policy_violated?: boolean; + repository?: string; + security_scan_status?: string; + slug_perm?: string; + stage_str?: string; + status_str?: string; + tags?: Record; +} + +export interface ListPackagesData { + packages?: TrimmedPackageData[]; +} + +// Promote Package + +export interface PromotePackageConfiguration { + sourceRepository?: string; + package?: string; + destinationRepository?: string; + mode?: string; +} + +export interface PromotePackageResult { + name?: string; + version?: string; + format?: string; + repository?: string; + namespace?: string; + status_str?: string; + stage_str?: string; + self_webapp_url?: string; + slug_perm?: string; +}