Skip to content
Merged
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
72 changes: 72 additions & 0 deletions docs/schemas/gcp/artifactregistryconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,78 @@
],
"type": "object"
},
"cleanupPolicies": {
"items": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"action": {
"type": "string"
},
"condition": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"newerThan": {
"type": "string"
},
"olderThan": {
"type": "string"
},
"packageNamePrefixes": {
"items": {
"type": "string"
},
"type": "array"
},
"tagPrefixes": {
"items": {
"type": "string"
},
"type": "array"
},
"tagState": {
"type": "string"
},
"versionNamePrefixes": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [],
"type": "object"
},
"mostRecentVersions": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"keepCount": {
"type": "integer"
},
"packageNamePrefixes": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [],
"type": "object"
},
"name": {
"type": "string"
}
},
"required": [
"action",
"name"
],
"type": "object"
},
"type": "array"
},
"cleanupPolicyDryRun": {
"type": "boolean"
},
"docker": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
Expand Down
6 changes: 6 additions & 0 deletions pkg/api/tests/refapp_gke_autopilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ var ResolvedRefappGkeAutopilotServerResources = map[string]api.ResourceDescripto
Config: &gcloud.ArtifactRegistryConfig{
Credentials: ResolvedCommonGcpCredentials,
Location: "europe-west3",
// Empty rather than nil because this fixture is post-resolution:
// the placeholder resolver deep-copies by reflection and calls
// reflect.MakeSlice for every slice kind, so an omitted list
// arrives as an empty non-nil slice. Not managed either way,
// which ManagesCleanupPolicies asserts.
CleanupPolicies: []gcloud.ArtifactRegistryCleanupPolicy{},
},
},
Inherit: api.Inherit{},
Expand Down
49 changes: 49 additions & 0 deletions pkg/clouds/gcloud/artifactregistry.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,55 @@ type ArtifactRegistryConfig struct {
Docker *DockerConfig `json:"docker,omitempty" yaml:"docker,omitempty"`
Domain *string `json:"domain" yaml:"domain"`
BasicAuth *RegistryBasicAuth `json:"basicAuth,omitempty" yaml:"basicAuth,omitempty"`

// CleanupPolicies declares image retention for the repository.
//
// The provider treats cleanupPolicies as authoritative (unlike labels, whose
// schema documents itself as non-authoritative), so a repository resource
// that does not declare it will CLEAR any policy set by another tool on the
// next provision. Leaving this empty therefore means "SC does not manage
// retention", and SC preserves whatever is configured out of band rather
// than deleting it. See ManagesCleanupPolicies.
CleanupPolicies []ArtifactRegistryCleanupPolicy `json:"cleanupPolicies,omitempty" yaml:"cleanupPolicies,omitempty"`

// CleanupPolicyDryRun evaluates the policies and reports what they would
// delete without deleting it. Only meaningful alongside CleanupPolicies.
CleanupPolicyDryRun *bool `json:"cleanupPolicyDryRun,omitempty" yaml:"cleanupPolicyDryRun,omitempty"`
}

// ManagesCleanupPolicies reports whether retention is declared here. When it is
// not, the caller must tell Pulumi to ignore the field rather than send an empty
// value, which is the difference between "not managed" and "delete the policy".
func (c *ArtifactRegistryConfig) ManagesCleanupPolicies() bool {
return len(c.CleanupPolicies) > 0
}

// ArtifactRegistryCleanupPolicy mirrors a single Artifact Registry cleanup
// policy. Durations are the API's second-suffixed form, e.g. "2592000s".
type ArtifactRegistryCleanupPolicy struct {
// Name identifies the policy within the repository.
Name string `json:"name" yaml:"name"`
// Action is DELETE or KEEP (case-insensitive).
Action string `json:"action" yaml:"action"`
Condition *ArtifactRegistryCleanupPolicyCondition `json:"condition,omitempty" yaml:"condition,omitempty"`
// MostRecentVersions retains a minimum number of versions. KEEP only.
MostRecentVersions *ArtifactRegistryCleanupMostRecentVersions `json:"mostRecentVersions,omitempty" yaml:"mostRecentVersions,omitempty"`
}

type ArtifactRegistryCleanupPolicyCondition struct {
// TagState is TAGGED, UNTAGGED or ANY.
TagState string `json:"tagState,omitempty" yaml:"tagState,omitempty"`
// OlderThan / NewerThan are durations, e.g. "2592000s" for 30 days.
OlderThan string `json:"olderThan,omitempty" yaml:"olderThan,omitempty"`
NewerThan string `json:"newerThan,omitempty" yaml:"newerThan,omitempty"`
TagPrefixes []string `json:"tagPrefixes,omitempty" yaml:"tagPrefixes,omitempty"`
PackageNamePrefixes []string `json:"packageNamePrefixes,omitempty" yaml:"packageNamePrefixes,omitempty"`
VersionNamePrefixes []string `json:"versionNamePrefixes,omitempty" yaml:"versionNamePrefixes,omitempty"`
}

type ArtifactRegistryCleanupMostRecentVersions struct {
KeepCount *int `json:"keepCount,omitempty" yaml:"keepCount,omitempty"`
PackageNamePrefixes []string `json:"packageNamePrefixes,omitempty" yaml:"packageNamePrefixes,omitempty"`
}

type RegistryBasicAuth struct {
Expand Down
97 changes: 97 additions & 0 deletions pkg/clouds/pulumi/gcp/artifactregistry.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,21 @@ func ArtifactRegistry(ctx *sdk.Context, stack api.Stack, input api.ResourceInput
return nil, errors.Errorf("registry format is not supported")
}

// cleanupPolicies is authoritative in the provider: a Repository resource
// that omits it sends an update clearing whatever is there. So either
// declare it, or tell the engine to leave it alone. Sending nothing is the
// one option that silently deletes another tool's retention policy.
if arCfg.ManagesCleanupPolicies() {
policies, err := cleanupPolicyArgs(arCfg.CleanupPolicies)
if err != nil {
return nil, err
}
repoArgs.CleanupPolicies = policies
repoArgs.CleanupPolicyDryRun = sdk.Bool(lo.FromPtr(arCfg.CleanupPolicyDryRun))
} else {
opts = append(opts, sdk.IgnoreChanges(cleanupPolicyFields))
}

params.Log.Info(ctx.Context(), "configure artifact registry repository %q", artifactRegistryName)
repo, err := artifactregistry.NewRepository(ctx, artifactRegistryName, &repoArgs, opts...)
if err != nil {
Expand Down Expand Up @@ -252,3 +267,85 @@ func toRegistryServiceAccountKeyExport(input api.ResourceInput, saType string, r
func toRegistryServiceAccountEmailExport(input api.ResourceInput, saType string, registryName string) string {
return input.ToResName(fmt.Sprintf("%s-%s-sa", saType, registryName))
}

// cleanupPolicyFields are the property paths SC declines to manage when no
// retention is configured. Both are needed: leaving cleanupPolicyDryRun out
// would let a provision flip an out-of-band dry-run repository into enforcing.
var cleanupPolicyFields = []string{"cleanupPolicies", "cleanupPolicyDryRun"}

// cleanupPolicyArgs converts the declared retention into provider inputs.
//
// Validation is deliberate rather than passing strings through: the provider
// rejects an unknown action or tagState at APPLY, and an Artifact Registry
// misconfiguration is measured in deleted images.
func cleanupPolicyArgs(policies []gcloud.ArtifactRegistryCleanupPolicy) (artifactregistry.RepositoryCleanupPolicyArray, error) {
out := make(artifactregistry.RepositoryCleanupPolicyArray, 0, len(policies))
seen := make(map[string]bool, len(policies))
for _, p := range policies {
if p.Name == "" {
return nil, errors.Errorf("cleanup policy is missing a name")
}
if seen[p.Name] {
return nil, errors.Errorf("duplicate cleanup policy name %q", p.Name)
}
seen[p.Name] = true

action := strings.ToUpper(p.Action)
if action != "DELETE" && action != "KEEP" {
return nil, errors.Errorf("cleanup policy %q: action must be DELETE or KEEP, got %q", p.Name, p.Action)
}
if p.MostRecentVersions != nil && action != "KEEP" {
return nil, errors.Errorf("cleanup policy %q: mostRecentVersions is only valid with a KEEP action", p.Name)
}
if p.Condition == nil && p.MostRecentVersions == nil {
return nil, errors.Errorf("cleanup policy %q: needs a condition or mostRecentVersions", p.Name)
}

args := &artifactregistry.RepositoryCleanupPolicyArgs{
Id: sdk.String(p.Name),
Action: sdk.String(action),
}
if c := p.Condition; c != nil {
tagState := strings.ToUpper(c.TagState)
switch tagState {
case "", "TAGGED", "UNTAGGED", "ANY":
default:
return nil, errors.Errorf("cleanup policy %q: tagState must be TAGGED, UNTAGGED or ANY, got %q", p.Name, c.TagState)
}
for field, v := range map[string]string{"olderThan": c.OlderThan, "newerThan": c.NewerThan} {
if v != "" && !strings.HasSuffix(v, "s") {
return nil, errors.Errorf("cleanup policy %q: %s must be a duration in seconds with an 's' suffix, e.g. \"2592000s\", got %q", p.Name, field, v)
}
}
cond := &artifactregistry.RepositoryCleanupPolicyConditionArgs{
TagPrefixes: sdk.ToStringArray(c.TagPrefixes),
PackageNamePrefixes: sdk.ToStringArray(c.PackageNamePrefixes),
VersionNamePrefixes: sdk.ToStringArray(c.VersionNamePrefixes),
}
if tagState != "" {
cond.TagState = sdk.StringPtr(tagState)
}
if c.OlderThan != "" {
cond.OlderThan = sdk.StringPtr(c.OlderThan)
}
if c.NewerThan != "" {
cond.NewerThan = sdk.StringPtr(c.NewerThan)
}
args.Condition = cond
}
if m := p.MostRecentVersions; m != nil {
if lo.FromPtr(m.KeepCount) < 0 {
return nil, errors.Errorf("cleanup policy %q: keepCount cannot be negative", p.Name)
}
mrv := &artifactregistry.RepositoryCleanupPolicyMostRecentVersionsArgs{
PackageNamePrefixes: sdk.ToStringArray(m.PackageNamePrefixes),
}
if m.KeepCount != nil {
mrv.KeepCount = sdk.IntPtr(*m.KeepCount)
}
args.MostRecentVersions = mrv
}
out = append(out, args)
}
return out, nil
}
Loading
Loading