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
89 changes: 89 additions & 0 deletions docs/components/DockerHub.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";

<CardGrid>
<LinkCard title="On Image Push" href="#on-image-push" description="Listen to DockerHub image push events" />
<LinkCard title="On Vulnerability Scan" href="#on-vulnerability-scan" description="Listen to Docker Scout vulnerability scan events for a DockerHub repository" />
</CardGrid>

## Actions

<CardGrid>
<LinkCard title="Delete Tag" href="#delete-tag" description="Delete a tag from a DockerHub repository" />
<LinkCard title="Get Image Tag" href="#get-image-tag" description="Get metadata for a DockerHub image tag" />
</CardGrid>

Expand Down Expand Up @@ -74,6 +76,93 @@ This trigger generates a webhook URL in SuperPlane. Add that URL as a DockerHub
}
```

<a id="on-vulnerability-scan"></a>

## On Vulnerability Scan

The On Vulnerability Scan trigger fires when Docker Scout completes a vulnerability scan for an image in a DockerHub repository.

### Use Cases

- **Security gating**: Block promotion pipelines when critical vulnerabilities are detected
- **Alerting**: Notify teams when newly pushed images contain high-severity CVEs
- **Remediation workflows**: Automatically open issues or tickets for vulnerable images

### Configuration

- **Repository**: DockerHub repository name, in the format of `namespace/name`
- **Minimum Severity**: Optional filter — only fire when the scan contains at least one vulnerability at this severity or higher (`low`, `medium`, `high`, `critical`)

### Webhook Setup

This trigger generates a webhook URL in SuperPlane. Register that URL as a Docker Scout notification webhook for the selected repository so Docker Scout can deliver scan completion events.

### Example Data

```json
{
"data": {
"event": {
"created_at": "2026-02-03T12:00:00Z",
"payload": {
"criticalities": {
"critical": 2,
"high": 5,
"low": 8,
"medium": 10,
"unspecified": 1
},
"digest": "sha256:abc123def456",
"fixable_count": 7,
"repository": {
"full_name": "superplane/demo",
"name": "demo",
"namespace": "superplane"
},
"tag": "v1.2.3"
},
"type": "image_vulnerability"
},
"stream": "vulnerability"
},
"timestamp": "2026-02-03T12:00:00Z",
"type": "dockerhub.image.vulnerability_scan"
}
```

<a id="delete-tag"></a>

## Delete Tag

The Delete Tag component permanently removes a tag from a DockerHub repository.

### Use Cases

- **Cleanup pipelines**: Remove stale or temporary tags after a deployment succeeds
- **Release workflows**: Delete RC or beta tags once a release is promoted to stable
- **Policy enforcement**: Prune tags that violate naming conventions

### Configuration

- **Repository**: DockerHub repository name, in the format of `namespace/name`
- **Tag**: Image tag to delete (for example: `v1.2.3-rc1`)

> **Warning**: This action is irreversible. The tag cannot be recovered after deletion.

### Example Output

```json
{
"data": {
"namespace": "superplane",
"repository": "demo",
"tag": "v1.2.3-rc1"
},
"timestamp": "2026-02-03T12:00:00Z",
"type": "dockerhub.deletedTag"
}
```

<a id="get-image-tag"></a>

## Get Image Tag
Expand Down
6 changes: 6 additions & 0 deletions pkg/integrations/dockerhub/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,9 @@ func (c *Client) GetRepositoryTag(namespace, repository, tag string) (*Tag, erro

return &result, nil
}

func (c *Client) DeleteRepositoryTag(namespace, repository, tag string) error {
path := fmt.Sprintf("/v2/namespaces/%s/repositories/%s/tags/%s", namespace, repository, tag)
_, _, err := c.doRequest(http.MethodDelete, path, nil)
return err
}
176 changes: 176 additions & 0 deletions pkg/integrations/dockerhub/delete_tag.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package dockerhub

import (
"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 DeleteTag struct{}

type DeleteTagConfiguration struct {
Repository string `json:"repository" mapstructure:"repository"`
Tag string `json:"tag" mapstructure:"tag"`
}

func (c *DeleteTag) Name() string {
return "dockerhub.deleteTag"
}

func (c *DeleteTag) Label() string {
return "Delete Tag"
}

func (c *DeleteTag) Description() string {
return "Delete a tag from a DockerHub repository"
}

func (c *DeleteTag) Documentation() string {
return `The Delete Tag component permanently removes a tag from a DockerHub repository.

## Use Cases

- **Cleanup pipelines**: Remove stale or temporary tags after a deployment succeeds
- **Release workflows**: Delete RC or beta tags once a release is promoted to stable
- **Policy enforcement**: Prune tags that violate naming conventions

## Configuration

- **Repository**: DockerHub repository name, in the format of ` + "`namespace/name`" + `
- **Tag**: Image tag to delete (for example: ` + "`v1.2.3-rc1`" + `)

> **Warning**: This action is irreversible. The tag cannot be recovered after deletion.
`
}

func (c *DeleteTag) Icon() string {
return "docker"
}

func (c *DeleteTag) Color() string {
return "gray"
}

func (c *DeleteTag) ExampleOutput() map[string]any {
return deleteTagExampleOutput()
}

func (c *DeleteTag) OutputChannels(configuration any) []core.OutputChannel {
return []core.OutputChannel{core.DefaultOutputChannel}
}

func (c *DeleteTag) Configuration() []configuration.Field {
return []configuration.Field{
{
Name: "repository",
Label: "Repository",
Type: configuration.FieldTypeIntegrationResource,
Required: true,
TypeOptions: &configuration.TypeOptions{
Resource: &configuration.ResourceTypeOptions{
Type: "dockerhub.repository",
},
},
},
{
Name: "tag",
Label: "Tag",
Type: configuration.FieldTypeString,
Required: true,
Placeholder: "v1.2.3-rc1",
},
}
}

func (c *DeleteTag) Setup(ctx core.SetupContext) error {
var config DeleteTagConfiguration
if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
return fmt.Errorf("failed to decode configuration: %w", err)
}

if strings.TrimSpace(config.Repository) == "" {
return fmt.Errorf("repository is required")
}

if strings.TrimSpace(config.Tag) == "" {
return fmt.Errorf("tag is required")
}

return nil
}

func (c *DeleteTag) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
return ctx.DefaultProcessing()
}

func (c *DeleteTag) Execute(ctx core.ExecutionContext) error {
var config DeleteTagConfiguration
if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
return fmt.Errorf("failed to decode configuration: %w", err)
}

repository := strings.TrimSpace(config.Repository)
if repository == "" {
return fmt.Errorf("repository is required")
}

tag := strings.TrimSpace(config.Tag)
if tag == "" {
return fmt.Errorf("tag is required")
}

parts := strings.Split(repository, "/")
if len(parts) != 2 {
return fmt.Errorf("repository must be in the format of namespace/name")
}

namespace := strings.TrimSpace(parts[0])
repositoryName := strings.TrimSpace(parts[1])
if namespace == "" || repositoryName == "" {
return fmt.Errorf("repository must be in the format of namespace/name")
}

client, err := NewClient(ctx.HTTP, ctx.Integration)
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}

if err := client.DeleteRepositoryTag(namespace, repositoryName, tag); err != nil {
return fmt.Errorf("failed to delete tag: %w", err)
}

return ctx.ExecutionState.Emit(
core.DefaultOutputChannel.Name,
"dockerhub.deletedTag",
[]any{map[string]any{
"namespace": namespace,
"repository": repositoryName,
"tag": tag,
}},
)
}

func (c *DeleteTag) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) {
return http.StatusOK, nil, nil
}

func (c *DeleteTag) Cancel(ctx core.ExecutionContext) error {
return nil
}

func (c *DeleteTag) Cleanup(ctx core.SetupContext) error {
return nil
}

func (c *DeleteTag) Hooks() []core.Hook {
return []core.Hook{}
}

func (c *DeleteTag) HandleHook(ctx core.ActionHookContext) error {
return nil
}
94 changes: 94 additions & 0 deletions pkg/integrations/dockerhub/delete_tag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package dockerhub

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__DeleteTag__Setup(t *testing.T) {
component := &DeleteTag{}

t.Run("invalid configuration -> error", func(t *testing.T) {
err := component.Setup(core.SetupContext{
Integration: &contexts.IntegrationContext{},
Metadata: &contexts.MetadataContext{},
Configuration: "invalid",
})

require.ErrorContains(t, err, "failed to decode configuration")
})

t.Run("missing repository -> error", func(t *testing.T) {
err := component.Setup(core.SetupContext{
Integration: &contexts.IntegrationContext{},
Metadata: &contexts.MetadataContext{},
Configuration: map[string]any{"tag": "v1.0.0"},
})

require.ErrorContains(t, err, "repository is required")
})

t.Run("missing tag -> error", func(t *testing.T) {
err := component.Setup(core.SetupContext{
Integration: &contexts.IntegrationContext{},
Metadata: &contexts.MetadataContext{},
Configuration: map[string]any{"repository": "superplane/demo"},
})

require.ErrorContains(t, err, "tag is required")
})

t.Run("valid configuration -> no error", func(t *testing.T) {
err := component.Setup(core.SetupContext{
HTTP: &contexts.HTTPContext{},
Metadata: &contexts.MetadataContext{},
Configuration: map[string]any{
"repository": "superplane/demo",
"tag": "v1.0.0",
},
})

require.NoError(t, err)
})
}

func Test__DeleteTag__Execute(t *testing.T) {
component := &DeleteTag{}

httpCtx := &contexts.HTTPContext{
Responses: []*http.Response{
{
StatusCode: http.StatusNoContent,
Body: io.NopCloser(strings.NewReader("")),
},
},
}

execState := &contexts.ExecutionStateContext{KVs: map[string]string{}}

err := component.Execute(core.ExecutionContext{
Integration: &contexts.IntegrationContext{
CurrentSecrets: map[string]core.IntegrationSecret{
accessTokenSecretName: {Name: accessTokenSecretName, Value: []byte("token")},
},
},
HTTP: httpCtx,
ExecutionState: execState,
Configuration: map[string]any{
"repository": "superplane/demo",
"tag": "v1.0.0",
},
})

require.NoError(t, err)
assert.Equal(t, core.DefaultOutputChannel.Name, execState.Channel)
assert.Equal(t, "dockerhub.deletedTag", execState.Type)
require.Len(t, execState.Payloads, 1)
}
Loading
Loading