|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "bytes" |
| 5 | + "encoding/json" |
| 6 | + "flag" |
| 7 | + "fmt" |
| 8 | + "io" |
| 9 | + "os" |
| 10 | + "os/exec" |
| 11 | + "regexp" |
| 12 | + "strings" |
| 13 | +) |
| 14 | + |
| 15 | +const defaultRegistryURI = "public.ecr.aws/conductorone" |
| 16 | + |
| 17 | +var sha256DigestPattern = regexp.MustCompile(`^(?:sha256:)?([A-Fa-f0-9]{64})$`) |
| 18 | + |
| 19 | +type config struct { |
| 20 | + repositoryName string |
| 21 | + versionTag string |
| 22 | + candidateTag string |
| 23 | + digestFile string |
| 24 | + registryURI string |
| 25 | +} |
| 26 | + |
| 27 | +type awsRunner interface { |
| 28 | + Run(args ...string) ([]byte, []byte, error) |
| 29 | +} |
| 30 | + |
| 31 | +type execAWSRunner struct { |
| 32 | + binary string |
| 33 | +} |
| 34 | + |
| 35 | +func (r execAWSRunner) Run(args ...string) ([]byte, []byte, error) { |
| 36 | + var stderr bytes.Buffer |
| 37 | + cmd := exec.Command(r.binary, args...) |
| 38 | + cmd.Stderr = &stderr |
| 39 | + stdout, err := cmd.Output() |
| 40 | + return stdout, stderr.Bytes(), err |
| 41 | +} |
| 42 | + |
| 43 | +func main() { |
| 44 | + cfg := config{ |
| 45 | + registryURI: defaultRegistryURI, |
| 46 | + } |
| 47 | + flag.StringVar(&cfg.repositoryName, "repository-name", "", "Public ECR repository name") |
| 48 | + flag.StringVar(&cfg.versionTag, "version-tag", "", "Immutable release version tag") |
| 49 | + flag.StringVar(&cfg.candidateTag, "candidate-tag", "", "Temporary candidate image tag pushed by GoReleaser") |
| 50 | + flag.StringVar(&cfg.digestFile, "digest-file", "", "Path to the GoReleaser docker_digest file") |
| 51 | + flag.StringVar(&cfg.registryURI, "registry-uri", defaultRegistryURI, "Public ECR registry URI") |
| 52 | + flag.Usage = func() { |
| 53 | + fmt.Fprintf(flag.CommandLine.Output(), "Usage: publish-public-ecr-release-tags -repository-name REPO -version-tag TAG -candidate-tag TAG -digest-file FILE [-registry-uri URI]\n\n") |
| 54 | + fmt.Fprintf(flag.CommandLine.Output(), "Promotes a pushed candidate Public ECR image to the immutable release version tag after checking any existing version tag digest. The latest tag is mutable convenience metadata and is not consulted by the preflight.\n\n") |
| 55 | + flag.PrintDefaults() |
| 56 | + } |
| 57 | + flag.Parse() |
| 58 | + |
| 59 | + if err := cfg.validate(); err != nil { |
| 60 | + fmt.Fprintln(os.Stderr, err) |
| 61 | + flag.Usage() |
| 62 | + os.Exit(2) |
| 63 | + } |
| 64 | + |
| 65 | + awsCLI := os.Getenv("AWS_CLI") |
| 66 | + if awsCLI == "" { |
| 67 | + awsCLI = "aws" |
| 68 | + } |
| 69 | + |
| 70 | + if err := publish(cfg, execAWSRunner{binary: awsCLI}, os.Stdout, os.Stderr); err != nil { |
| 71 | + fmt.Fprintln(os.Stderr, err) |
| 72 | + os.Exit(1) |
| 73 | + } |
| 74 | +} |
| 75 | + |
| 76 | +func (c config) validate() error { |
| 77 | + var missing []string |
| 78 | + if c.repositoryName == "" { |
| 79 | + missing = append(missing, "-repository-name") |
| 80 | + } |
| 81 | + if c.versionTag == "" { |
| 82 | + missing = append(missing, "-version-tag") |
| 83 | + } |
| 84 | + if c.candidateTag == "" { |
| 85 | + missing = append(missing, "-candidate-tag") |
| 86 | + } |
| 87 | + if c.digestFile == "" { |
| 88 | + missing = append(missing, "-digest-file") |
| 89 | + } |
| 90 | + if len(missing) > 0 { |
| 91 | + return fmt.Errorf("publish-public-ecr-release-tags: error: missing required flags: %s", strings.Join(missing, ", ")) |
| 92 | + } |
| 93 | + if c.registryURI == "" { |
| 94 | + return fmt.Errorf("publish-public-ecr-release-tags: error: -registry-uri must not be empty") |
| 95 | + } |
| 96 | + return nil |
| 97 | +} |
| 98 | + |
| 99 | +func publish(cfg config, aws awsRunner, stdout, stderr io.Writer) error { |
| 100 | + imageBase := strings.TrimRight(cfg.registryURI, "/") + "/" + cfg.repositoryName |
| 101 | + candidateRef := imageBase + ":" + cfg.candidateTag |
| 102 | + versionRef := imageBase + ":" + cfg.versionTag |
| 103 | + |
| 104 | + candidateDigest, err := candidateDigestFromFile(cfg.digestFile, candidateRef) |
| 105 | + if err != nil { |
| 106 | + return err |
| 107 | + } |
| 108 | + |
| 109 | + existingDigest, err := describeImageDigest(aws, cfg.repositoryName, cfg.versionTag) |
| 110 | + if err != nil { |
| 111 | + return err |
| 112 | + } |
| 113 | + versionTagExists := existingDigest != "" |
| 114 | + |
| 115 | + if versionTagExists { |
| 116 | + if existingDigest != candidateDigest { |
| 117 | + return fmt.Errorf("::error::Public ECR tag %s:%s already points at %s, refusing to replace it with %s", cfg.repositoryName, cfg.versionTag, existingDigest, candidateDigest) |
| 118 | + } |
| 119 | + fmt.Fprintf(stdout, "Public ECR version tag already points at %s; keeping release idempotent\n", candidateDigest) |
| 120 | + } else { |
| 121 | + fmt.Fprintf(stdout, "Public ECR version tag %s:%s is available\n", cfg.repositoryName, cfg.versionTag) |
| 122 | + } |
| 123 | + |
| 124 | + manifest, err := imageManifest(aws, cfg.repositoryName, candidateDigest) |
| 125 | + if err != nil { |
| 126 | + return err |
| 127 | + } |
| 128 | + |
| 129 | + if !versionTagExists { |
| 130 | + if err := putImageTag(aws, cfg.repositoryName, manifest, cfg.versionTag); err != nil { |
| 131 | + return err |
| 132 | + } |
| 133 | + } else { |
| 134 | + fmt.Fprintf(stdout, "Skipped Public ECR version tag write because %s:%s already has %s\n", cfg.repositoryName, cfg.versionTag, candidateDigest) |
| 135 | + } |
| 136 | + |
| 137 | + publishedDigest, err := describeImageDigest(aws, cfg.repositoryName, cfg.versionTag) |
| 138 | + if err != nil { |
| 139 | + return err |
| 140 | + } |
| 141 | + if publishedDigest == "" { |
| 142 | + return fmt.Errorf("::error::Public ECR tag %s:%s was not found after publication", cfg.repositoryName, cfg.versionTag) |
| 143 | + } |
| 144 | + if publishedDigest != candidateDigest { |
| 145 | + return fmt.Errorf("::error::Public ECR tag %s:%s points at %s after publication, expected %s", cfg.repositoryName, cfg.versionTag, publishedDigest, candidateDigest) |
| 146 | + } |
| 147 | + |
| 148 | + if err := putImageTag(aws, cfg.repositoryName, manifest, "latest"); err != nil { |
| 149 | + return err |
| 150 | + } |
| 151 | + |
| 152 | + if err := os.WriteFile(cfg.digestFile, []byte(fmt.Sprintf("%s %s\n", candidateDigest, versionRef)), 0o644); err != nil { |
| 153 | + return fmt.Errorf("publish-public-ecr-release-tags: error: rewriting digest file: %w", err) |
| 154 | + } |
| 155 | + |
| 156 | + if err := deleteImageTag(aws, cfg.repositoryName, cfg.candidateTag); err != nil { |
| 157 | + fmt.Fprintf(stderr, "::warning::Could not remove temporary Public ECR candidate tag %s: %v\n", cfg.candidateTag, err) |
| 158 | + } else { |
| 159 | + fmt.Fprintf(stdout, "Removed temporary Public ECR candidate tag %s\n", cfg.candidateTag) |
| 160 | + } |
| 161 | + |
| 162 | + fmt.Fprintf(stdout, "Published %s at %s\n", versionRef, candidateDigest) |
| 163 | + return nil |
| 164 | +} |
| 165 | + |
| 166 | +func candidateDigestFromFile(path, candidateRef string) (string, error) { |
| 167 | + content, err := os.ReadFile(path) |
| 168 | + if err != nil { |
| 169 | + if os.IsNotExist(err) { |
| 170 | + return "", fmt.Errorf("Digest file not found: %s", path) |
| 171 | + } |
| 172 | + return "", fmt.Errorf("publish-public-ecr-release-tags: error: reading digest file %s: %w", path, err) |
| 173 | + } |
| 174 | + |
| 175 | + for _, line := range strings.Split(string(content), "\n") { |
| 176 | + fields := strings.Fields(line) |
| 177 | + if len(fields) < 2 || fields[1] != candidateRef { |
| 178 | + continue |
| 179 | + } |
| 180 | + digest, err := normalizeDigest(fields[0]) |
| 181 | + if err != nil { |
| 182 | + return "", fmt.Errorf("publish-public-ecr-release-tags: error: candidate digest for %s in %s is invalid: %w", candidateRef, path, err) |
| 183 | + } |
| 184 | + return digest, nil |
| 185 | + } |
| 186 | + |
| 187 | + return "", fmt.Errorf("Could not find candidate ref %s in %s\n%s", candidateRef, path, content) |
| 188 | +} |
| 189 | + |
| 190 | +func describeImageDigest(aws awsRunner, repositoryName, tag string) (string, error) { |
| 191 | + args := []string{ |
| 192 | + "ecr-public", "describe-images", |
| 193 | + "--repository-name", repositoryName, |
| 194 | + "--image-ids", "imageTag=" + tag, |
| 195 | + "--output", "json", |
| 196 | + } |
| 197 | + stdout, stderr, err := aws.Run(args...) |
| 198 | + if err != nil { |
| 199 | + if isImageNotFound(stderr) { |
| 200 | + return "", nil |
| 201 | + } |
| 202 | + return "", awsError(args, stderr, err) |
| 203 | + } |
| 204 | + |
| 205 | + var response struct { |
| 206 | + ImageDetails []struct { |
| 207 | + ImageDigest string `json:"imageDigest"` |
| 208 | + } `json:"imageDetails"` |
| 209 | + } |
| 210 | + if err := json.Unmarshal(stdout, &response); err != nil { |
| 211 | + return "", fmt.Errorf("publish-public-ecr-release-tags: error: parsing describe-images response for %s:%s: %w", repositoryName, tag, err) |
| 212 | + } |
| 213 | + if len(response.ImageDetails) == 0 || response.ImageDetails[0].ImageDigest == "" { |
| 214 | + return "", nil |
| 215 | + } |
| 216 | + |
| 217 | + digest, err := normalizeDigest(response.ImageDetails[0].ImageDigest) |
| 218 | + if err != nil { |
| 219 | + return "", fmt.Errorf("publish-public-ecr-release-tags: error: describe-images returned invalid digest for %s:%s: %w", repositoryName, tag, err) |
| 220 | + } |
| 221 | + return digest, nil |
| 222 | +} |
| 223 | + |
| 224 | +func imageManifest(aws awsRunner, repositoryName, digest string) (string, error) { |
| 225 | + args := []string{ |
| 226 | + "ecr-public", "batch-get-image", |
| 227 | + "--repository-name", repositoryName, |
| 228 | + "--image-ids", "imageDigest=" + digest, |
| 229 | + "--accepted-media-types", |
| 230 | + "application/vnd.oci.image.index.v1+json", |
| 231 | + "application/vnd.docker.distribution.manifest.list.v2+json", |
| 232 | + "application/vnd.oci.image.manifest.v1+json", |
| 233 | + "application/vnd.docker.distribution.manifest.v2+json", |
| 234 | + "--output", "json", |
| 235 | + } |
| 236 | + stdout, stderr, err := aws.Run(args...) |
| 237 | + if err != nil { |
| 238 | + return "", awsError(args, stderr, err) |
| 239 | + } |
| 240 | + |
| 241 | + var response struct { |
| 242 | + Images []struct { |
| 243 | + ImageManifest string `json:"imageManifest"` |
| 244 | + } `json:"images"` |
| 245 | + } |
| 246 | + if err := json.Unmarshal(stdout, &response); err != nil { |
| 247 | + return "", fmt.Errorf("publish-public-ecr-release-tags: error: parsing batch-get-image response for %s@%s: %w", repositoryName, digest, err) |
| 248 | + } |
| 249 | + if len(response.Images) == 0 || response.Images[0].ImageManifest == "" { |
| 250 | + return "", fmt.Errorf("Could not fetch manifest for %s@%s\n%s", repositoryName, digest, stdout) |
| 251 | + } |
| 252 | + return response.Images[0].ImageManifest, nil |
| 253 | +} |
| 254 | + |
| 255 | +func putImageTag(aws awsRunner, repositoryName, manifest, tag string) error { |
| 256 | + args := []string{ |
| 257 | + "ecr-public", "put-image", |
| 258 | + "--repository-name", repositoryName, |
| 259 | + "--image-manifest", manifest, |
| 260 | + "--image-tag", tag, |
| 261 | + } |
| 262 | + _, stderr, err := aws.Run(args...) |
| 263 | + if err != nil { |
| 264 | + return awsError(args, stderr, err) |
| 265 | + } |
| 266 | + return nil |
| 267 | +} |
| 268 | + |
| 269 | +func deleteImageTag(aws awsRunner, repositoryName, tag string) error { |
| 270 | + args := []string{ |
| 271 | + "ecr-public", "batch-delete-image", |
| 272 | + "--repository-name", repositoryName, |
| 273 | + "--image-ids", "imageTag=" + tag, |
| 274 | + } |
| 275 | + _, stderr, err := aws.Run(args...) |
| 276 | + if err != nil { |
| 277 | + return awsError(args, stderr, err) |
| 278 | + } |
| 279 | + return nil |
| 280 | +} |
| 281 | + |
| 282 | +func normalizeDigest(raw string) (string, error) { |
| 283 | + raw = strings.TrimSpace(raw) |
| 284 | + matches := sha256DigestPattern.FindStringSubmatch(raw) |
| 285 | + if matches == nil { |
| 286 | + return "", fmt.Errorf("expected sha256 digest, got %q", raw) |
| 287 | + } |
| 288 | + return "sha256:" + strings.ToLower(matches[1]), nil |
| 289 | +} |
| 290 | + |
| 291 | +func isImageNotFound(stderr []byte) bool { |
| 292 | + message := string(stderr) |
| 293 | + return strings.Contains(message, "ImageNotFound") || |
| 294 | + strings.Contains(message, "ImageNotFoundException") || |
| 295 | + strings.Contains(message, "RepositoryNotFound") || |
| 296 | + strings.Contains(message, "RepositoryNotFoundException") |
| 297 | +} |
| 298 | + |
| 299 | +func awsError(args []string, stderr []byte, err error) error { |
| 300 | + message := strings.TrimSpace(string(stderr)) |
| 301 | + if message == "" { |
| 302 | + return fmt.Errorf("publish-public-ecr-release-tags: error: aws %s: %w", strings.Join(args, " "), err) |
| 303 | + } |
| 304 | + return fmt.Errorf("publish-public-ecr-release-tags: error: aws %s: %s: %w", strings.Join(args, " "), message, err) |
| 305 | +} |
0 commit comments