diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index a915bad..46b7b16 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,8 +1,5 @@ name: Build and push image and chart -on: - push: - branches: - - main +on: push env: NAME: nais-api-reconcilers IMAGE_REPOSITORY: oci://europe-north1-docker.pkg.dev/nais-io/nais diff --git a/internal/reconcilers/google/gar/reconciler.go b/internal/reconcilers/google/gar/reconciler.go index 8ffdfac..e116d87 100644 --- a/internal/reconcilers/google/gar/reconciler.go +++ b/internal/reconcilers/google/gar/reconciler.go @@ -3,16 +3,13 @@ package google_gar_reconciler import ( "context" "fmt" + "maps" "net/http" + "time" artifactregistry "cloud.google.com/go/artifactregistry/apiv1" "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb" "cloud.google.com/go/iam/apiv1/iampb" - "github.com/nais/api-reconcilers/internal/gcp" - "github.com/nais/api-reconcilers/internal/google_token_source" - "github.com/nais/api-reconcilers/internal/reconcilers" - github_team_reconciler "github.com/nais/api-reconcilers/internal/reconcilers/github/team" - str "github.com/nais/api-reconcilers/internal/strings" "github.com/nais/api/pkg/apiclient" "github.com/nais/api/pkg/protoapi" "github.com/sirupsen/logrus" @@ -23,8 +20,16 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/fieldmaskpb" "k8s.io/utils/ptr" + + "github.com/nais/api-reconcilers/internal/gcp" + "github.com/nais/api-reconcilers/internal/google_token_source" + "github.com/nais/api-reconcilers/internal/reconcilers" + github_team_reconciler "github.com/nais/api-reconcilers/internal/reconcilers/github/team" + str "github.com/nais/api-reconcilers/internal/strings" ) const ( @@ -299,6 +304,18 @@ func (r *garReconciler) updateGarRepository(ctx context.Context, repository *art changes = append(changes, "description") } + targetPolicies := DefaultCleanupPolicies() + policyUpToDate := maps.EqualFunc(targetPolicies, repository.CleanupPolicies, func(a, b *artifactregistrypb.CleanupPolicy) bool { + return proto.Equal(a, b) + }) + + if !policyUpToDate || repository.CleanupPolicyDryRun { + repository.CleanupPolicyDryRun = false + repository.CleanupPolicies = targetPolicies + changes = append(changes, "cleanup_policies") + changes = append(changes, "cleanup_policy_dry_run") + } + if len(changes) > 0 { updateRequest := &artifactregistrypb.UpdateRepositoryRequest{ Repository: repository, @@ -344,3 +361,39 @@ func serviceAccountNameAndAccountID(teamSlug, projectID string) (serviceAccountN serviceAccountName = "projects/" + projectID + "/serviceAccounts/" + emailAddress return } + +// Remove all images that are more than 90 days old, but keep the last 50 "versions" regardless of age. +// These numbers are also referenced in our own documentation at: https://doc.nais.io/how-to-guides/github-action/; try to keep them in sync. +// +// Each "build and push" includes artifacts such as signatures and attestations that seemingly count as "versions". +// Thus, an "image" actually consists of 5 artifacts at the worst (for images pushed through the nais/docker-build-push action) +// +// Documentation: https://cloud.google.com/artifact-registry/docs/repositories/cleanup-policy +func DefaultCleanupPolicies() map[string]*artifactregistrypb.CleanupPolicy { + var keepCount int32 = 50 + + keepUntilAge := time.Hour * 24 * 90 + anyTagState := artifactregistrypb.CleanupPolicyCondition_ANY + + return map[string]*artifactregistrypb.CleanupPolicy{ + "delete_old_images": { + Id: "delete_old_images", + Action: artifactregistrypb.CleanupPolicy_DELETE, + ConditionType: &artifactregistrypb.CleanupPolicy_Condition{ + Condition: &artifactregistrypb.CleanupPolicyCondition{ + TagState: &anyTagState, + OlderThan: durationpb.New(keepUntilAge), + }, + }, + }, + "keep_latest_versions": { + Id: "keep_latest_versions", + Action: artifactregistrypb.CleanupPolicy_KEEP, + ConditionType: &artifactregistrypb.CleanupPolicy_MostRecentVersions{ + MostRecentVersions: &artifactregistrypb.CleanupPolicyMostRecentVersions{ + KeepCount: &keepCount, + }, + }, + }, + } +} diff --git a/internal/reconcilers/google/gar/reconciler_test.go b/internal/reconcilers/google/gar/reconciler_test.go index 9dfa564..5e111ea 100644 --- a/internal/reconcilers/google/gar/reconciler_test.go +++ b/internal/reconcilers/google/gar/reconciler_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "net" "net/http" "net/http/httptest" @@ -15,9 +16,6 @@ import ( "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb" "cloud.google.com/go/iam/apiv1/iampb" "cloud.google.com/go/longrunning/autogen/longrunningpb" - github_team_reconciler "github.com/nais/api-reconcilers/internal/reconcilers/github/team" - google_gar_reconciler "github.com/nais/api-reconcilers/internal/reconcilers/google/gar" - "github.com/nais/api-reconcilers/internal/test" "github.com/nais/api/pkg/apiclient" "github.com/nais/api/pkg/protoapi" "github.com/sirupsen/logrus" @@ -33,6 +31,10 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" "k8s.io/utils/ptr" + + github_team_reconciler "github.com/nais/api-reconcilers/internal/reconcilers/github/team" + google_gar_reconciler "github.com/nais/api-reconcilers/internal/reconcilers/google/gar" + "github.com/nais/api-reconcilers/internal/test" ) type fakeArtifactRegistry struct { @@ -175,6 +177,7 @@ func TestReconcile(t *testing.T) { "team": teamSlug, "managed-by": "api-reconcilers", }, + CleanupPolicies: google_gar_reconciler.DefaultCleanupPolicies(), } ctx := context.Background() @@ -548,6 +551,74 @@ func TestReconcile(t *testing.T) { t.Errorf("unexpected error: %v", err) } }) + + t.Run("gar repository exists, but has no cleanup policies", func(t *testing.T) { + naisTeam := &protoapi.Team{ + Slug: teamSlug, + } + + mocks := mocks{ + artifactRegistry: &fakeArtifactRegistry{ + get: func(ctx context.Context, r *artifactregistrypb.GetRepositoryRequest) (*artifactregistrypb.Repository, error) { + repositoryWithoutCleanupPolicies := proto.Clone(&expectedRepository).(*artifactregistrypb.Repository) + repositoryWithoutCleanupPolicies.CleanupPolicies = nil + + return repositoryWithoutCleanupPolicies, nil + }, + update: func(ctx context.Context, r *artifactregistrypb.UpdateRepositoryRequest) (*artifactregistrypb.Repository, error) { + if r.Repository.CleanupPolicies == nil { + t.Errorf("expected cleanup policies to be set, got nil") + } + + if r.Repository.CleanupPolicyDryRun { + t.Errorf("expected cleanup policy dry run to be false, got true") + } + + expectedPolicies := google_gar_reconciler.DefaultCleanupPolicies() + policyUpToDate := maps.EqualFunc(expectedPolicies, r.Repository.CleanupPolicies, func(a, b *artifactregistrypb.CleanupPolicy) bool { + return proto.Equal(a, b) + }) + + if !policyUpToDate { + t.Errorf("expected cleanup policies to be %v, got %v", expectedPolicies, r.Repository.CleanupPolicies) + } + + return nil, abortTestErr + }, + }, + iam: test.HttpServerWithHandlers(t, []http.HandlerFunc{ + // get service account + func(w http.ResponseWriter, r *http.Request) { + if err := json.NewEncoder(w).Encode(expectedServiceAccount); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }, + // set iam policy + func(w http.ResponseWriter, r *http.Request) { + if err := json.NewEncoder(w).Encode(&iam.Policy{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }, + }), + } + + artifactregistryClient, iamService := mocks.start(t, ctx) + + apiClient, mockServer := apiclient.NewMockClient(t) + mockServer.Reconcilers.EXPECT(). + State(mock.Anything, &protoapi.GetReconcilerStateRequest{ReconcilerName: "github:team", TeamSlug: teamSlug}). + Return(&protoapi.GetReconcilerStateResponse{}, nil). + Once() + + reconciler, err := google_gar_reconciler.New(ctx, serviceAccountEmail, managementProjectID, workloadIdentityPoolName, google_gar_reconciler.WithGarClient(artifactregistryClient), google_gar_reconciler.WithIAMService(iamService)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if err = reconciler.Reconcile(ctx, apiClient, naisTeam, log); !strings.Contains(err.Error(), "abort test") { + t.Errorf("unexpected error: %v", err) + } + }) } func TestDelete(t *testing.T) {