Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions docs/components/Cloudsmith.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
title: "Cloudsmith"
---

Manage and react to Cloudsmith package repositories

import { CardGrid, LinkCard } from "@astrojs/starlight/components";

## Triggers

<CardGrid>
<LinkCard title="On Package Event" href="#on-package-event" description="Triggers when a package event occurs in a Cloudsmith repository" />
</CardGrid>

## Actions

<CardGrid>
<LinkCard title="Get Package" href="#get-package" description="Fetch details of a Cloudsmith package" />
</CardGrid>

## Instructions

To generate a Cloudsmith API token:
- Go to **Cloudsmith** → **Account Settings** → **API Key**
- Copy the API key and enter it below, along with your organization or user workspace (slug)

<a id="on-package-event"></a>

## On Package Event

The On Package Event trigger starts a workflow execution when a package event occurs in a Cloudsmith repository.

### Use Cases

- **Build pipelines**: Trigger downstream workflows when a new package is synchronized
- **Release workflows**: Automate promotion or notification on package publish
- **Security automation**: React to quarantined or failed packages

### Configuration

- **Repository**: Cloudsmith repository in the format `namespace/repo`
- **Events**: Package events to listen for (e.g. `package.synced`, `package.deleted`)

### Webhook Setup

This trigger creates a webhook in Cloudsmith automatically when the canvas is saved.

### Example Data

```json
{
"data": {
"event": "package.synced",
"package": {
"cdn_url": "https://dl.cloudsmith.io/basic/my-org/my-repo/python/simple/my-library/",
"checksum_sha256": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
"format": "python",
"name": "my-library",
"size": 45678,
"slug_perm": "Wcpkg1234abcd",
"status_str": "Synchronised",
"version": "1.2.3"
},
"repository": {
"name": "my-repo",
"namespace": "my-org",
"slug": "my-repo"
}
},
"timestamp": "2026-02-03T12:00:00Z",
"type": "cloudsmith.package.event"
}
```

<a id="get-package"></a>

## Get Package

The Get Package component retrieves metadata for a Cloudsmith package.

### Use Cases

- **Release automation**: Fetch package details for downstream deployments
- **Audit trails**: Resolve package metadata for traceability
- **Insights**: Inspect package sizes, checksums, and status

### Configuration

- **Repository**: Cloudsmith repository in the format `namespace/repo`
- **Identifier**: Package identifier or slug (for example: `Wklm1a2b`)

### Example Output

```json
{
"data": {
"cdn_url": "https://dl.cloudsmith.io/basic/my-org/my-repo/python/simple/my-library/",
"checksum_sha256": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
"format": "python",
"name": "my-library",
"size": 45678,
"status_str": "Synchronised",
"version": "1.2.3"
},
"timestamp": "2026-02-03T12:00:00Z",
"type": "cloudsmith.package"
}
```

206 changes: 206 additions & 0 deletions pkg/integrations/cloudsmith/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package cloudsmith

import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"

"github.com/superplanehq/superplane/pkg/core"
)

const (
defaultBaseURL = "https://api.cloudsmith.io"
)

type Client struct {
APIKey string
Workspace string
BaseURL string
http core.HTTPContext
}

func NewClient(httpClient core.HTTPContext, integration core.IntegrationContext) (*Client, error) {
if integration == nil {
return nil, fmt.Errorf("no integration context")
}

apiKey, err := integration.GetConfig("apiKey")
if err != nil {
return nil, fmt.Errorf("API key not configured: %w", err)
}

key := strings.TrimSpace(string(apiKey))
if key == "" {
return nil, fmt.Errorf("API key is required")
}

workspace, err := integration.GetConfig("workspace")
if err != nil {
return nil, fmt.Errorf("workspace not configured: %w", err)
}

ws := strings.TrimSpace(string(workspace))

return &Client{
APIKey: key,
Workspace: ws,
BaseURL: defaultBaseURL,
http: httpClient,
}, nil
}

func (c *Client) doRequest(method, path string, body io.Reader) (*http.Response, []byte, error) {
finalURL := path
if !strings.HasPrefix(path, "http") {
finalURL = c.BaseURL + path
}

req, err := http.NewRequest(method, finalURL, body)
if err != nil {
return nil, nil, fmt.Errorf("failed to build request: %w", err)
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", c.APIKey)

res, err := c.http.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("request failed: %w", err)
}
defer res.Body.Close()

responseBody, err := io.ReadAll(res.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}

if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
return nil, nil, fmt.Errorf("request failed with %d: %s", res.StatusCode, string(responseBody))
}

return res, responseBody, nil
}

func (c *Client) ValidateCredentials() error {
_, _, err := c.doRequest(http.MethodGet, "/user/self/", nil)
return err
}

type Repository struct {
Name string `json:"name"`
Slug string `json:"slug"`
}

func (c *Client) ListRepositories(namespace string) ([]Repository, error) {
namespace = strings.TrimSpace(namespace)
if namespace == "" {
return nil, fmt.Errorf("namespace is required")
}

path := fmt.Sprintf("/repos/%s/", namespace)
_, responseBody, err := c.doRequest(http.MethodGet, path, nil)
if err != nil {
return nil, err
}

var repositories []Repository
if err := json.Unmarshal(responseBody, &repositories); err != nil {
return nil, fmt.Errorf("failed to parse repositories response: %w", err)
}

return repositories, nil
}

type PackageInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Format string `json:"format"`
Size int64 `json:"size"`
ChecksumSHA256 string `json:"checksum_sha256"`
CDNURL string `json:"cdn_url"`
Status string `json:"status_str"`
}

func (c *Client) GetPackage(namespace, repo, identifier string) (*PackageInfo, error) {
if namespace == "" || repo == "" || identifier == "" {
return nil, fmt.Errorf("namespace, repo, and identifier are required")
}

path := fmt.Sprintf("/packages/%s/%s/%s/", namespace, repo, identifier)
_, responseBody, err := c.doRequest(http.MethodGet, path, nil)
if err != nil {
return nil, err
}

var pkg PackageInfo
if err := json.Unmarshal(responseBody, &pkg); err != nil {
return nil, fmt.Errorf("failed to parse package response: %w", err)
}

return &pkg, nil
}

type WebhookTemplate struct {
Event string `json:"event"`
Template string `json:"template"`
}

type CreateWebhookRequest struct {
TargetURL string `json:"target_url"`
Events []string `json:"events"`
IsActive bool `json:"is_active"`
Templates []WebhookTemplate `json:"templates"`
}

type CreateWebhookResponse struct {
SlugPerm string `json:"slug_perm"`
}

func (c *Client) CreateWebhook(namespace, repo, targetURL string, events []string) (string, error) {
if namespace == "" || repo == "" || targetURL == "" {
return "", fmt.Errorf("namespace, repo, and targetURL are required")
}

// Cloudsmith requires a templates entry per subscribed event. The Template field
// holds a Handlebars template string for custom payload formatting; an empty string
// uses the default JSON payload (request_body_format = 0).
templates := make([]WebhookTemplate, 0, len(events))
for _, event := range events {
templates = append(templates, WebhookTemplate{Event: event, Template: ""})
}

payload, err := json.Marshal(CreateWebhookRequest{
TargetURL: targetURL,
Events: events,
IsActive: true,
Templates: templates,
})
if err != nil {
return "", fmt.Errorf("failed to marshal webhook request: %w", err)
}

path := fmt.Sprintf("/webhooks/%s/%s/", namespace, repo)
_, responseBody, err := c.doRequest(http.MethodPost, path, strings.NewReader(string(payload)))
if err != nil {
return "", err
}

var response CreateWebhookResponse
if err := json.Unmarshal(responseBody, &response); err != nil {
return "", fmt.Errorf("failed to parse webhook response: %w", err)
}

return response.SlugPerm, nil
}

func (c *Client) DeleteWebhook(namespace, repo, slugPerm string) error {
if namespace == "" || repo == "" || slugPerm == "" {
return fmt.Errorf("namespace, repo, and slugPerm are required")
}

path := fmt.Sprintf("/webhooks/%s/%s/%s/", namespace, repo, slugPerm)
_, _, err := c.doRequest(http.MethodDelete, path, nil)
return err
}
66 changes: 66 additions & 0 deletions pkg/integrations/cloudsmith/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package cloudsmith

import (
"io"
"net/http"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/superplanehq/superplane/test/support/contexts"
)

func Test__Cloudsmith__NewClient(t *testing.T) {
t.Run("missing API key -> error", func(t *testing.T) {
integrationCtx := &contexts.IntegrationContext{}

_, err := NewClient(&contexts.HTTPContext{}, integrationCtx)
require.Error(t, err)
assert.Contains(t, err.Error(), "API key not configured")
})

t.Run("valid configuration -> client created", func(t *testing.T) {
integrationCtx := &contexts.IntegrationContext{
Configuration: map[string]any{
"apiKey": "test-api-key",
"workspace": "my-org",
},
}

client, err := NewClient(&contexts.HTTPContext{}, integrationCtx)
require.NoError(t, err)
assert.Equal(t, "test-api-key", client.APIKey)
assert.Equal(t, "my-org", client.Workspace)
})
}

func Test__Cloudsmith__ListRepositories(t *testing.T) {
httpCtx := &contexts.HTTPContext{
Responses: []*http.Response{
{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`[{"name":"my-repo","slug":"my-repo"}]`)),
},
},
}

integrationCtx := &contexts.IntegrationContext{
Configuration: map[string]any{
"apiKey": "test-api-key",
"workspace": "my-org",
},
}

client, err := NewClient(httpCtx, integrationCtx)
require.NoError(t, err)

repos, err := client.ListRepositories("my-org")
require.NoError(t, err)
require.Len(t, repos, 1)
assert.Equal(t, "my-repo", repos[0].Name)

require.Len(t, httpCtx.Requests, 1)
assert.Contains(t, httpCtx.Requests[0].URL.String(), "/repos/my-org/")
assert.Equal(t, "test-api-key", httpCtx.Requests[0].Header.Get("X-Api-Key"))
}
Loading