From cb369f17b9b7c990d3aabf565a0a68529cd43a42 Mon Sep 17 00:00:00 2001 From: Adam Charlton Date: Fri, 12 Jun 2026 12:45:02 -0600 Subject: [PATCH 1/2] Named tag support for Satisfy. --- cmd/satisfy/main.go | 11 ++ contracts/config_tags.go | 32 ++++++ contracts/config_upload.go | 17 +-- contracts/manifest.go | 15 +++ contracts/manifest_test.go | 36 ++++++ contracts/remote_storage.go | 6 + core/dependency_resolver.go | 81 +++++++++++-- core/dependency_resolver_test.go | 127 +++++++++++++++++++- core/installer.go | 1 + core/tags.go | 55 +++++++++ core/tags_config_loader.go | 142 +++++++++++++++++++++++ core/tags_config_loader_test.go | 185 ++++++++++++++++++++++++++++++ core/tags_test.go | 79 +++++++++++++ core/upload_config_loader.go | 29 ++++- core/upload_config_loader_test.go | 36 ++++++ transfer/tags.go | 138 ++++++++++++++++++++++ transfer/upload.go | 41 ++++++- 17 files changed, 1005 insertions(+), 26 deletions(-) create mode 100644 contracts/config_tags.go create mode 100644 core/tags.go create mode 100644 core/tags_config_loader.go create mode 100644 core/tags_config_loader_test.go create mode 100644 core/tags_test.go create mode 100644 transfer/tags.go diff --git a/cmd/satisfy/main.go b/cmd/satisfy/main.go index ba0d03f..0be6f55 100644 --- a/cmd/satisfy/main.go +++ b/cmd/satisfy/main.go @@ -23,6 +23,8 @@ func main() { checkMain(os.Args[2:]) case "latest": latestMain(os.Args[2:]) + case "tags": + tagsMain(os.Args[2:]) case "version": versionMain() case "download": @@ -58,6 +60,15 @@ func downloadMain(args []string) { transfer.NewDownloadApp(config).Run() } +func tagsMain(args []string) { + loader := core.NewTagsConfigLoader(shell.NewDiskFileSystem(""), shell.NewEnvironment(), os.Stdin, os.Stderr) + config, err := loader.LoadConfig(args) + if err != nil { + log.Fatal(err) + } + transfer.NewTagsApp(config).Run() +} + func latestMain(args []string) { config, err := transfer.ParseLatestConfig(args) if err != nil { diff --git a/contracts/config_tags.go b/contracts/config_tags.go new file mode 100644 index 0000000..c3ff1eb --- /dev/null +++ b/contracts/config_tags.go @@ -0,0 +1,32 @@ +package contracts + +import ( + "net/url" + "path" + + "github.com/smarty/gcs" +) + +type TagsConfig struct { + MaxRetry int + GoogleCredentials gcs.Credentials + JSONPath string + Modification TagModificationConfig +} + +type TagModificationConfig struct { + PackageName string `json:"package_name"` + RemoteAddress *URL `json:"remote_address"` + Add []Tag `json:"add"` + Delete []Tag `json:"delete"` // only the name of each entry is considered +} + +func (this TagModificationConfig) ComposeRootManifestAddress() url.URL { + address := url.URL(*this.RemoteAddress) + address.Path = path.Join("/", address.Path, this.PackageName, RemoteManifestFilename) + return address +} + +func (this TagModificationConfig) ComposeVersionedManifestAddress(version string) url.URL { + return AppendRemotePath(url.URL(*this.RemoteAddress), this.PackageName, version, RemoteManifestFilename) +} diff --git a/contracts/config_upload.go b/contracts/config_upload.go index 741d435..772e1bf 100644 --- a/contracts/config_upload.go +++ b/contracts/config_upload.go @@ -18,14 +18,15 @@ type UploadConfig struct { } type PackageConfig struct { - CompressionAlgorithm string `json:"compression_algorithm"` - CompressionLevel int `json:"compression_level"` - SourceDirectory string `json:"source_directory"` - SourceFile string `json:"source_file"` - SourcePath string `json:"source_path"` - PackageName string `json:"package_name"` - PackageVersion string `json:"package_version"` - RemoteAddressPrefix *URL `json:"remote_address"` + CompressionAlgorithm string `json:"compression_algorithm"` + CompressionLevel int `json:"compression_level"` + SourceDirectory string `json:"source_directory"` + SourceFile string `json:"source_file"` + SourcePath string `json:"source_path"` + PackageName string `json:"package_name"` + PackageVersion string `json:"package_version"` + RemoteAddressPrefix *URL `json:"remote_address"` + Tags []string `json:"tags,omitempty"` } func (this PackageConfig) ComposeRemoteAddress(filename string) url.URL { diff --git a/contracts/manifest.go b/contracts/manifest.go index d62223b..d044102 100644 --- a/contracts/manifest.go +++ b/contracts/manifest.go @@ -4,6 +4,21 @@ type Manifest struct { Name string `json:"name"` //a-z 0-9 _-/ Version string `json:"version"` Archive Archive `json:"archive"` + Tags []Tag `json:"tags,omitempty"` // only present in the root (latest) manifest +} + +type Tag struct { + Name string `json:"name"` + Version string `json:"version"` +} + +func (this Manifest) TagVersion(name string) (version string, found bool) { + for _, tag := range this.Tags { + if tag.Name == name { + return tag.Version, true + } + } + return "", false } type Archive struct { diff --git a/contracts/manifest_test.go b/contracts/manifest_test.go index 85f272a..0695af7 100644 --- a/contracts/manifest_test.go +++ b/contracts/manifest_test.go @@ -34,6 +34,42 @@ func (this *ManifestFixture) TestMarshalManifest() { this.So(clone, should.Resemble, original) } +func (this *ManifestFixture) TestMarshalManifestWithTags() { + original := Manifest{ + Name: "package-name", + Version: "1.2.3", + Tags: []Tag{ + {Name: "stable", Version: "1.2.2"}, + {Name: "experimental", Version: "1.2.3"}, + }, + } + clone := this.unmarshal(this.marshal(original)) + this.So(clone, should.Resemble, original) +} + +func (this *ManifestFixture) TestTagsOmittedFromJSONWhenEmpty() { + raw := this.marshal(Manifest{Name: "package-name", Version: "1.2.3"}) + this.So(string(raw), should.NotContainSubstring, "tags") +} + +func (this *ManifestFixture) TestTagVersion() { + manifest := Manifest{ + Version: "1.2.3", + Tags: []Tag{ + {Name: "stable", Version: "1.2.2"}, + {Name: "experimental", Version: "1.2.3"}, + }, + } + + version, found := manifest.TagVersion("stable") + this.So(found, should.BeTrue) + this.So(version, should.Equal, "1.2.2") + + version, found = manifest.TagVersion("nope") + this.So(found, should.BeFalse) + this.So(version, should.BeBlank) +} + func (this *ManifestFixture) unmarshal(raw []byte) Manifest { var clone Manifest err := json.Unmarshal(raw, &clone) diff --git a/contracts/remote_storage.go b/contracts/remote_storage.go index 780531a..7738d0c 100644 --- a/contracts/remote_storage.go +++ b/contracts/remote_storage.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io" + "net/http" "net/url" "path" "strconv" @@ -51,6 +52,11 @@ func AppendRemotePath(prefix url.URL, packageName, version, fileName string) url var RetryErr = errors.New("retry") +func IsNotFound(err error) bool { + var statusError *StatusCodeError + return errors.As(err, &statusError) && statusError.StatusCode() == http.StatusNotFound +} + type StatusCodeError struct { actualStatusCode int expectedStatusCode []int diff --git a/core/dependency_resolver.go b/core/dependency_resolver.go index 2c04fcb..fe299ea 100644 --- a/core/dependency_resolver.go +++ b/core/dependency_resolver.go @@ -51,7 +51,11 @@ func (this *DependencyResolver) Resolve() error { return err } - if this.isInstalledCorrectly(localManifest) { + installed, err := this.isInstalledCorrectly(localManifest) + if err != nil { + return err + } + if installed { return nil } @@ -80,34 +84,78 @@ func (this *DependencyResolver) localManifestExists(manifestPath string) bool { return !os.IsNotExist(err) } -func (this *DependencyResolver) isInstalledCorrectly(localManifest contracts.Manifest) bool { +func (this *DependencyResolver) isInstalledCorrectly(localManifest contracts.Manifest) (bool, error) { if localManifest.Name != this.dependency.PackageName { if strings.HasSuffix(localManifest.Name, "/"+this.dependency.PackageName) { // no-op } else { log.Printf("incorrect package installed (%s), proceeding to installation of specified package: %s", localManifest.Name, this.dependency.Title()) - return false + return false, nil } } if this.dependency.PackageVersion == "latest" && !this.localManifestIsLatest(localManifest) { log.Printf("incorrect version installed (%s), proceeding to installation of specified package: %s", localManifest.Version, this.dependency.Title()) - return false + return false, nil } else if this.dependency.PackageVersion != "latest" && localManifest.Version != this.dependency.PackageVersion { - log.Printf("incorrect version installed (%s), proceeding to installation of specified package: %s", - localManifest.Version, this.dependency.Title()) - return false + matchesTag, err := this.localManifestMatchesRequestedTag(localManifest) + if err != nil { + return false, err + } + if !matchesTag { + log.Printf("incorrect version installed (%s), proceeding to installation of specified package: %s", + localManifest.Version, this.dependency.Title()) + return false, nil + } } verifyErr := this.integrityChecker.Verify(localManifest, this.dependency.LocalDirectory) if verifyErr != nil { log.Printf("%s in %s", verifyErr.Error(), this.dependency.Title()) - return false + return false, nil } log.Printf("Dependency already installed: %s", this.dependency.Title()) - return true + return true, nil +} + +// localManifestMatchesRequestedTag is consulted when the requested version does +// not match the locally installed version. A remote manifest at the literal +// version path takes precedence over a tag of the same name; only when no such +// version exists is the requested string resolved against the root manifest tags. +func (this *DependencyResolver) localManifestMatchesRequestedTag(localManifest contracts.Manifest) (bool, error) { + _, err := this.packageInstaller.DownloadManifest(this.dependency.ComposeRemoteAddress(contracts.RemoteManifestFilename)) + if err == nil { + return false, nil + } + if !contracts.IsNotFound(err) { + return false, fmt.Errorf("failed to download manifest for %s: %w", this.dependency.Title(), err) + } + + version, err := this.resolveTag() + if err != nil { + return false, err + } + this.dependency.PackageVersion = version + return version == localManifest.Version, nil +} + +// resolveTag translates the requested version into a concrete version by way of +// the tag listing in the root manifest. +func (this *DependencyResolver) resolveTag() (string, error) { + requested := this.dependency.PackageVersion + rootManifest, err := this.packageInstaller.DownloadManifest(this.dependency.ComposeLatestManifestRemoteAddress()) + if err != nil { + return "", fmt.Errorf("failed to download root manifest while resolving %q for package %q: %w", + requested, this.dependency.PackageName, err) + } + version, found := rootManifest.TagVersion(requested) + if !found { + return "", fmt.Errorf("no version or tag %q exists for package %q", requested, this.dependency.PackageName) + } + log.Printf("Resolved tag %q to version %q for package %q", requested, version, this.dependency.PackageName) + return version, nil } func (this *DependencyResolver) installPackage() error { @@ -117,6 +165,21 @@ func (this *DependencyResolver) installPackage() error { LocalPath: this.dependency.LocalDirectory, PackageName: this.dependency.PackageName, }) + if contracts.IsNotFound(err) && this.dependency.PackageVersion != "latest" { + // No manifest exists at the literal version path; the requested + // version may be a tag defined in the root manifest. + var version string + version, err = this.resolveTag() + if err != nil { + return fmt.Errorf("failed to install manifest for %s: %w", this.dependency.Title(), err) + } + this.dependency.PackageVersion = version + manifest, err = this.packageInstaller.InstallManifest(contracts.InstallationRequest{ + RemoteAddress: this.dependency.ComposeRemoteManifestAddress(), + LocalPath: this.dependency.LocalDirectory, + PackageName: this.dependency.PackageName, + }) + } if err != nil { return fmt.Errorf("failed to install manifest for %s: %w", this.dependency.Title(), err) } diff --git a/core/dependency_resolver_test.go b/core/dependency_resolver_test.go index 14597ca..29efa33 100644 --- a/core/dependency_resolver_test.go +++ b/core/dependency_resolver_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "net/url" "testing" @@ -225,6 +226,116 @@ func (this *DependencyResolverFixture) TestLatestManifestFailsToDownload() { this.So(this.fileSystem.fileSystem, should.NotContainKey, "local/contents3") } +func (this *DependencyResolverFixture) TestTagFreshInstallation() { + manifest := contracts.Manifest{ + Name: "B/C", + Version: "D", + Archive: contracts.Archive{Filename: "archive-name"}, + } + this.packageInstaller.remote = manifest + this.dependency.PackageVersion = "stable" + this.packageInstaller.errsByAddress = map[string]error{ + "gcs://A/B/C/stable/manifest.json": this.notFound("gcs://A/B/C/stable/manifest.json"), + } + this.packageInstaller.manifestsByAddress = map[string]contracts.Manifest{ + "gcs://A/B/C/manifest.json": {Name: "B/C", Version: "E", Tags: []contracts.Tag{{Name: "stable", Version: "D"}}}, + } + + err := this.Resolve() + + this.So(err, should.BeNil) + this.assertNewPackageInstalled(manifest.Name, "D") +} + +func (this *DependencyResolverFixture) TestTagAlreadyInstalledCorrectly() { + this.prepareLocalPackageAndManifest(this.dependency.PackageName, "D") + this.dependency.PackageVersion = "stable" + this.packageInstaller.errsByAddress = map[string]error{ + "gcs://A/B/C/stable/manifest.json": this.notFound("gcs://A/B/C/stable/manifest.json"), + } + this.packageInstaller.manifestsByAddress = map[string]contracts.Manifest{ + "gcs://A/B/C/manifest.json": {Name: "B/C", Version: "E", Tags: []contracts.Tag{{Name: "stable", Version: "D"}}}, + } + + err := this.Resolve() + + this.So(err, should.BeNil) + this.So(this.packageInstaller.installManifestCounter, should.Equal, 0) + this.So(this.packageInstaller.installPackageCounter, should.Equal, 0) + this.So(this.fileSystem.fileSystem, should.ContainKey, "local/contents1") +} + +func (this *DependencyResolverFixture) TestTagPointsToDifferentVersion() { + this.prepareLocalPackageAndManifest(this.dependency.PackageName, "D") + this.dependency.PackageVersion = "stable" + this.packageInstaller.remote = contracts.Manifest{Name: "B/C", Version: "E"} + this.packageInstaller.errsByAddress = map[string]error{ + "gcs://A/B/C/stable/manifest.json": this.notFound("gcs://A/B/C/stable/manifest.json"), + } + this.packageInstaller.manifestsByAddress = map[string]contracts.Manifest{ + "gcs://A/B/C/manifest.json": {Name: "B/C", Version: "E", Tags: []contracts.Tag{{Name: "stable", Version: "E"}}}, + } + + err := this.Resolve() + + this.So(err, should.BeNil) + this.assertPreviouslyInstalledPackageUninstalled() + this.assertNewPackageInstalled("B/C", "E") +} + +func (this *DependencyResolverFixture) TestUnknownVersionOrTagPreservesLocalInstallation() { + this.prepareLocalPackageAndManifest(this.dependency.PackageName, "D") + this.dependency.PackageVersion = "no-such-version-or-tag" + this.packageInstaller.errsByAddress = map[string]error{ + "gcs://A/B/C/no-such-version-or-tag/manifest.json": this.notFound("gcs://A/B/C/no-such-version-or-tag/manifest.json"), + } + this.packageInstaller.manifestsByAddress = map[string]contracts.Manifest{ + "gcs://A/B/C/manifest.json": {Name: "B/C", Version: "E", Tags: []contracts.Tag{{Name: "stable", Version: "D"}}}, + } + + err := this.Resolve() + + this.So(err, should.NotBeNil) + this.So(this.packageInstaller.installPackageCounter, should.Equal, 0) + this.So(this.fileSystem.fileSystem, should.ContainKey, "local/contents1") + this.So(this.fileSystem.fileSystem, should.ContainKey, "local/contents2") + this.So(this.fileSystem.fileSystem, should.ContainKey, "local/contents3") +} + +func (this *DependencyResolverFixture) TestLiteralVersionShadowsTagOfSameName() { + this.prepareLocalPackageAndManifest(this.dependency.PackageName, "D") + this.dependency.PackageVersion = "stable" + this.packageInstaller.remote = contracts.Manifest{Name: "B/C", Version: "stable"} + this.packageInstaller.manifestsByAddress = map[string]contracts.Manifest{ + "gcs://A/B/C/stable/manifest.json": {Name: "B/C", Version: "stable"}, + "gcs://A/B/C/manifest.json": {Name: "B/C", Version: "E", Tags: []contracts.Tag{{Name: "stable", Version: "D"}}}, + } + + err := this.Resolve() + + this.So(err, should.BeNil) + this.assertPreviouslyInstalledPackageUninstalled() + this.assertNewPackageInstalled("B/C", "stable") +} + +func (this *DependencyResolverFixture) TestTagResolutionFailsWhenRootManifestUnavailable() { + rootErr := errors.New("root manifest unavailable") + this.dependency.PackageVersion = "stable" + this.packageInstaller.errsByAddress = map[string]error{ + "gcs://A/B/C/stable/manifest.json": this.notFound("gcs://A/B/C/stable/manifest.json"), + "gcs://A/B/C/manifest.json": rootErr, + } + + err := this.Resolve() + + this.So(errors.Is(err, rootErr), should.BeTrue) + this.So(this.packageInstaller.installPackageCounter, should.Equal, 0) +} + +func (this *DependencyResolverFixture) notFound(address string) error { + return contracts.NewStatusCodeError(http.StatusNotFound, []int{http.StatusOK}, this.URL(address)) +} + func (this *DependencyResolverFixture) assertNewPackageInstalled(name, version string) { this.So(this.packageInstaller.installed, should.Resemble, this.packageInstaller.remote) this.So(this.packageInstaller.manifestRequest, should.Resemble, contracts.InstallationRequest{ @@ -286,15 +397,29 @@ type FakePackageInstaller struct { installManifestCounter int installPackageCounter int downloadError error + manifestsByAddress map[string]contracts.Manifest + errsByAddress map[string]error } -func (this *FakePackageInstaller) DownloadManifest(url.URL) (manifest contracts.Manifest, err error) { +func (this *FakePackageInstaller) DownloadManifest(address url.URL) (manifest contracts.Manifest, err error) { + if err, found := this.errsByAddress[address.String()]; found { + return contracts.Manifest{}, err + } + if manifest, found := this.manifestsByAddress[address.String()]; found { + return manifest, nil + } return this.remoteLatest, this.downloadError } func (this *FakePackageInstaller) InstallManifest(request contracts.InstallationRequest) (manifest contracts.Manifest, err error) { this.installManifestCounter++ this.manifestRequest = request + if err, found := this.errsByAddress[request.RemoteAddress.String()]; found { + return contracts.Manifest{}, err + } + if manifest, found := this.manifestsByAddress[request.RemoteAddress.String()]; found { + return manifest, nil + } return this.remote, this.installManifestErr } diff --git a/core/installer.go b/core/installer.go index 60eff44..9b57763 100644 --- a/core/installer.go +++ b/core/installer.go @@ -63,6 +63,7 @@ func (this *PackageInstaller) InstallManifest(request contracts.InstallationRequ } manifest.Name = request.PackageName + manifest.Tags = nil // the tag listing only belongs in the remote root manifest rawManifest, err := json.MarshalIndent(manifest, "", " ") if err != nil { return contracts.Manifest{}, err diff --git a/core/tags.go b/core/tags.go new file mode 100644 index 0000000..c7a934a --- /dev/null +++ b/core/tags.go @@ -0,0 +1,55 @@ +package core + +import ( + "sort" + + "github.com/smarty/satisfy/contracts" +) + +// MergeTags points each named tag at the supplied version while preserving +// unrelated existing tags. The result is sorted by tag name. +func MergeTags(existing []contracts.Tag, names []string, version string) []contracts.Tag { + merged := append([]contracts.Tag(nil), existing...) + for _, name := range names { + merged = upsertTag(merged, contracts.Tag{Name: name, Version: version}) + } + sortTags(merged) + return merged +} + +// ApplyTagModifications adds or updates each tag in add, then removes each tag +// named in remove (a no-op for names not present). The result is sorted by tag name. +func ApplyTagModifications(existing, add, remove []contracts.Tag) []contracts.Tag { + merged := append([]contracts.Tag(nil), existing...) + for _, tag := range add { + merged = upsertTag(merged, tag) + } + for _, tag := range remove { + merged = removeTag(merged, tag.Name) + } + sortTags(merged) + return merged +} + +func upsertTag(tags []contracts.Tag, tag contracts.Tag) []contracts.Tag { + for i := range tags { + if tags[i].Name == tag.Name { + tags[i].Version = tag.Version + return tags + } + } + return append(tags, tag) +} + +func removeTag(tags []contracts.Tag, name string) []contracts.Tag { + for i := range tags { + if tags[i].Name == name { + return append(tags[:i], tags[i+1:]...) + } + } + return tags +} + +func sortTags(tags []contracts.Tag) { + sort.Slice(tags, func(i, j int) bool { return tags[i].Name < tags[j].Name }) +} diff --git a/core/tags_config_loader.go b/core/tags_config_loader.go new file mode 100644 index 0000000..8001a76 --- /dev/null +++ b/core/tags_config_loader.go @@ -0,0 +1,142 @@ +package core + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "log" + + "github.com/smarty/gcs" + + "github.com/smarty/satisfy/contracts" +) + +type TagsConfigLoader struct { + reader gcs.CredentialsReader + storage contracts.FileReader + stdin io.Reader + stderr io.Writer +} + +func NewTagsConfigLoader(storage contracts.FileReader, env contracts.Environment, stdin io.Reader, stderr io.Writer) *TagsConfigLoader { + vaultAddress, _ := env.LookupEnv("VAULT_ADDR") + vaultToken, _ := env.LookupEnv("VAULT_TOKEN") + + reader := gcs.NewCredentialsReader( + gcs.CredentialOptions.VaultServer(vaultAddress, vaultToken), + gcs.CredentialOptions.EnvironmentReader(env), + gcs.CredentialOptions.FileReader(storage)) + + return &TagsConfigLoader{ + reader: reader, + storage: storage, + stdin: stdin, + stderr: stderr, + } +} + +func (this *TagsConfigLoader) LoadConfig(args []string) (config contracts.TagsConfig, err error) { + config, err = this.parseCLI(args) + if err != nil { + return contracts.TagsConfig{}, err + } + + config.Modification, err = this.parseConfigFile(config.JSONPath) + if err != nil { + log.Printf("[Error] Error parsing configuration file: [%s]", err) + return contracts.TagsConfig{}, err + } + + config.GoogleCredentials, err = this.reader.Read(context.Background(), "") + if err != nil { + log.Printf("[Error] Google authentication failed: [%s]", err) + return contracts.TagsConfig{}, err + } + + err = this.validate(config) + if err != nil { + return contracts.TagsConfig{}, err + } + + return config, nil +} + +func (this *TagsConfigLoader) parseCLI(args []string) (config contracts.TagsConfig, err error) { + flags := flag.NewFlagSet("satisfy tags", flag.ContinueOnError) + flags.SetOutput(this.stderr) + flags.StringVar(&config.JSONPath, + "json", + "_STDIN_", + "Path to file with config file or, if equal to _STDIN_, read from stdin.", + ) + flags.IntVar(&config.MaxRetry, + "max-retry", + 5, + "HTTP max retry.", + ) + flags.Usage = func() { + _, _ = fmt.Fprintln(this.stderr, "Usage of satisfy tags:") + flags.PrintDefaults() + _, _ = fmt.Fprintln(this.stderr, ` +exit code 0: success +exit code 1: general failure (see stderr for details)`) + } + err = flags.Parse(args) + + return config, err +} + +func (this *TagsConfigLoader) parseConfigFile(path string) (config contracts.TagModificationConfig, err error) { + data, err := readRawJSON(this.storage, this.stdin, path) + if err != nil { + return contracts.TagModificationConfig{}, err + } + return config, json.Unmarshal(data, &config) +} + +func (this *TagsConfigLoader) validate(config contracts.TagsConfig) error { + if config.MaxRetry < 0 { + return maxRetryErr + } + modification := config.Modification + if modification.PackageName == "" { + return blankPackageNameErr + } + if modification.RemoteAddress == nil { + return nilRemoteAddressPrefixErr + } + if len(modification.Add) == 0 && len(modification.Delete) == 0 { + return noTagModificationsErr + } + + added := make(map[string]struct{}) + for _, tag := range modification.Add { + if err := validateTagName(tag.Name); err != nil { + return err + } + if tag.Version == "" { + return blankTagVersionErr + } + if _, found := added[tag.Name]; found { + return duplicateTagNameErr + } + added[tag.Name] = struct{}{} + } + + deleted := make(map[string]struct{}) + for _, tag := range modification.Delete { + if tag.Name == "" { + return blankTagNameErr + } + if _, found := added[tag.Name]; found { + return conflictingTagNameErr + } + if _, found := deleted[tag.Name]; found { + return duplicateTagNameErr + } + deleted[tag.Name] = struct{}{} + } + return nil +} diff --git a/core/tags_config_loader_test.go b/core/tags_config_loader_test.go new file mode 100644 index 0000000..230d8a9 --- /dev/null +++ b/core/tags_config_loader_test.go @@ -0,0 +1,185 @@ +package core + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/smarty/assertions/should" + "github.com/smarty/gunit" + "github.com/smarty/satisfy/contracts" +) + +func TestTagsConfigLoaderFixture(t *testing.T) { + gunit.Run(new(TagsConfigLoaderFixture), t) +} + +type TagsConfigLoaderFixture struct { + *gunit.Fixture + + loader *TagsConfigLoader + storage *inMemoryFileSystem + environment FakeEnvironment + stdin *bytes.Buffer + modification contracts.TagModificationConfig +} + +func (this *TagsConfigLoaderFixture) Setup() { + this.stdin = new(bytes.Buffer) + this.storage = newInMemoryFileSystem() + this.environment = make(FakeEnvironment) + this.loader = NewTagsConfigLoader(this.storage, this.environment, this.stdin, io.Discard) + credentialsPath := "/path/to/google-credentials.json" + this.environment["GOOGLE_APPLICATION_CREDENTIALS"] = credentialsPath + this.storage.WriteFile(strings.TrimSpace(credentialsPath), []byte(googleCredentialsJSON)) + this.modification = contracts.TagModificationConfig{ + PackageName: "cat-sound-data", + RemoteAddress: &contracts.URL{Scheme: "gcs", Host: "host", Path: "/path"}, + Add: []contracts.Tag{ + {Name: "stable", Version: "2026.02.A"}, + {Name: "marks-favorite", Version: "2026.01.B"}, + }, + Delete: []contracts.Tag{ + {Name: "active-build-test"}, + }, + } +} + +func (this *TagsConfigLoaderFixture) prepareConfigFile() { + raw, _ := json.Marshal(this.modification) + this.storage.WriteFile("tags.json", raw) +} + +func (this *TagsConfigLoaderFixture) loadConfig() (contracts.TagsConfig, error) { + this.prepareConfigFile() + return this.loader.LoadConfig([]string{"-json", "tags.json"}) +} + +func (this *TagsConfigLoaderFixture) TestValidConfigFromSpecifiedFile() { + this.prepareConfigFile() + + config, err := this.loader.LoadConfig([]string{"-max-retry", "10", "-json", "tags.json"}) + + this.So(err, should.BeNil) + this.So(config.MaxRetry, should.Equal, 10) + this.So(config.JSONPath, should.Equal, "tags.json") + this.So(config.GoogleCredentials, should.Resemble, parsedGoogleCredentials) + this.So(config.Modification, should.Resemble, this.modification) +} + +func (this *TagsConfigLoaderFixture) TestValidConfigFromStdIn() { + raw, _ := json.Marshal(this.modification) + this.stdin.Write(raw) + + config, err := this.loader.LoadConfig([]string{"-json", "_STDIN_"}) + + this.So(err, should.BeNil) + this.So(config.Modification, should.Resemble, this.modification) +} + +func (this *TagsConfigLoaderFixture) TestInvalidCLI() { + config, err := this.loader.LoadConfig([]string{"-max-retry", "Hello, world!"}) + + this.So(err, should.NotBeNil) + this.So(config, should.BeZeroValue) +} + +func (this *TagsConfigLoaderFixture) TestMalformedJSON() { + this.storage.WriteFile("tags.json", []byte("Invalid JSON")) + + config, err := this.loader.LoadConfig([]string{"-json", "tags.json"}) + + this.So(err, should.NotBeNil) + this.So(config.Modification, should.BeZeroValue) +} + +func (this *TagsConfigLoaderFixture) TestNegativeMaxRetry() { + this.prepareConfigFile() + + _, err := this.loader.LoadConfig([]string{"-max-retry", "-10", "-json", "tags.json"}) + + this.So(err, should.Resemble, maxRetryErr) +} + +func (this *TagsConfigLoaderFixture) TestBlankPackageName() { + this.modification.PackageName = "" + + _, err := this.loadConfig() + + this.So(err, should.Resemble, blankPackageNameErr) +} + +func (this *TagsConfigLoaderFixture) TestNilRemoteAddress() { + this.modification.RemoteAddress = nil + + _, err := this.loadConfig() + + this.So(err, should.Resemble, nilRemoteAddressPrefixErr) +} + +func (this *TagsConfigLoaderFixture) TestNoModifications() { + this.modification.Add = nil + this.modification.Delete = nil + + _, err := this.loadConfig() + + this.So(err, should.Resemble, noTagModificationsErr) +} + +func (this *TagsConfigLoaderFixture) TestBlankAddedTagName() { + this.modification.Add = append(this.modification.Add, contracts.Tag{Name: "", Version: "2026.01.A"}) + + _, err := this.loadConfig() + + this.So(err, should.Resemble, blankTagNameErr) +} + +func (this *TagsConfigLoaderFixture) TestReservedAddedTagName() { + this.modification.Add = append(this.modification.Add, contracts.Tag{Name: "latest", Version: "2026.01.A"}) + + _, err := this.loadConfig() + + this.So(err, should.Resemble, reservedTagNameErr) +} + +func (this *TagsConfigLoaderFixture) TestBlankAddedTagVersion() { + this.modification.Add = append(this.modification.Add, contracts.Tag{Name: "stale", Version: ""}) + + _, err := this.loadConfig() + + this.So(err, should.Resemble, blankTagVersionErr) +} + +func (this *TagsConfigLoaderFixture) TestDuplicateAddedTagName() { + this.modification.Add = append(this.modification.Add, contracts.Tag{Name: "stable", Version: "2026.01.A"}) + + _, err := this.loadConfig() + + this.So(err, should.Resemble, duplicateTagNameErr) +} + +func (this *TagsConfigLoaderFixture) TestBlankDeletedTagName() { + this.modification.Delete = append(this.modification.Delete, contracts.Tag{Name: ""}) + + _, err := this.loadConfig() + + this.So(err, should.Resemble, blankTagNameErr) +} + +func (this *TagsConfigLoaderFixture) TestDuplicateDeletedTagName() { + this.modification.Delete = append(this.modification.Delete, contracts.Tag{Name: "active-build-test"}) + + _, err := this.loadConfig() + + this.So(err, should.Resemble, duplicateTagNameErr) +} + +func (this *TagsConfigLoaderFixture) TestTagNameInBothAddAndDelete() { + this.modification.Delete = append(this.modification.Delete, contracts.Tag{Name: "stable"}) + + _, err := this.loadConfig() + + this.So(err, should.Resemble, conflictingTagNameErr) +} diff --git a/core/tags_test.go b/core/tags_test.go new file mode 100644 index 0000000..397a53b --- /dev/null +++ b/core/tags_test.go @@ -0,0 +1,79 @@ +package core + +import ( + "testing" + + "github.com/smarty/assertions/should" + "github.com/smarty/gunit" + "github.com/smarty/satisfy/contracts" +) + +func TestTagsFixture(t *testing.T) { + gunit.Run(new(TagsFixture), t) +} + +type TagsFixture struct { + *gunit.Fixture + existing []contracts.Tag +} + +func (this *TagsFixture) Setup() { + this.existing = []contracts.Tag{ + {Name: "stable", Version: "2026.01.A"}, + {Name: "experimental", Version: "2026.01.B"}, + } +} + +func (this *TagsFixture) TestMergeTagsUpdatesExistingAndAppendsNew() { + merged := MergeTags(this.existing, []string{"experimental", "release"}, "2026.02.A") + + this.So(merged, should.Resemble, []contracts.Tag{ + {Name: "experimental", Version: "2026.02.A"}, + {Name: "release", Version: "2026.02.A"}, + {Name: "stable", Version: "2026.01.A"}, + }) +} + +func (this *TagsFixture) TestMergeTagsWithNoNamesPreservesExisting() { + merged := MergeTags(this.existing, nil, "2026.02.A") + + this.So(merged, should.Resemble, []contracts.Tag{ + {Name: "experimental", Version: "2026.01.B"}, + {Name: "stable", Version: "2026.01.A"}, + }) +} + +func (this *TagsFixture) TestMergeTagsWithNothingAtAll() { + this.So(MergeTags(nil, nil, "2026.02.A"), should.BeEmpty) +} + +func (this *TagsFixture) TestMergeTagsDoesNotMutateExisting() { + _ = MergeTags(this.existing, []string{"stable"}, "2026.02.A") + + this.So(this.existing[0], should.Resemble, contracts.Tag{Name: "stable", Version: "2026.01.A"}) +} + +func (this *TagsFixture) TestApplyTagModifications() { + applied := ApplyTagModifications(this.existing, + []contracts.Tag{ + {Name: "stable", Version: "2026.01.B"}, + {Name: "marks-favorite", Version: "2026.01.B"}, + }, + []contracts.Tag{ + {Name: "experimental"}, + {Name: "never-existed"}, + }, + ) + + this.So(applied, should.Resemble, []contracts.Tag{ + {Name: "marks-favorite", Version: "2026.01.B"}, + {Name: "stable", Version: "2026.01.B"}, + }) +} + +func (this *TagsFixture) TestApplyTagModificationsCanDeleteEveryTag() { + applied := ApplyTagModifications(this.existing, nil, + []contracts.Tag{{Name: "stable"}, {Name: "experimental"}}) + + this.So(applied, should.BeEmpty) +} diff --git a/core/upload_config_loader.go b/core/upload_config_loader.go index 2d43a46..cd6e5f5 100644 --- a/core/upload_config_loader.go +++ b/core/upload_config_loader.go @@ -112,13 +112,17 @@ func (this *UploadConfigLoader) parseConfigFile(path string) (config contracts.P } func (this *UploadConfigLoader) readRawJSON(path string) (data []byte, err error) { + return readRawJSON(this.storage, this.stdin, path) +} + +func readRawJSON(storage contracts.FileReader, stdin io.Reader, path string) (data []byte, err error) { if path == "" { return nil, blankJSONPathErr } if path == "_STDIN_" { - return io.ReadAll(this.stdin) + return io.ReadAll(stdin) } else { - return this.storage.ReadFile(path) + return storage.ReadFile(path) } } @@ -141,6 +145,21 @@ func (this *UploadConfigLoader) validateConfigJsonValues(config contracts.Upload if config.PackageConfig.RemoteAddressPrefix == nil { return nilRemoteAddressPrefixErr } + for _, tag := range config.PackageConfig.Tags { + if err := validateTagName(tag); err != nil { + return err + } + } + return nil +} + +func validateTagName(name string) error { + if name == "" { + return blankTagNameErr + } + if name == "latest" { + return reservedTagNameErr + } return nil } @@ -152,4 +171,10 @@ var ( blankPackageNameErr = errors.New("package name should not be blank") blankPackageVersionErr = errors.New("package version should not be blank") nilRemoteAddressPrefixErr = errors.New("remote address prefix should not be nil") + blankTagNameErr = errors.New("tag name should not be blank") + reservedTagNameErr = errors.New("'latest' is a reserved tag name") + blankTagVersionErr = errors.New("tag version should not be blank") + duplicateTagNameErr = errors.New("tag name listed more than once") + conflictingTagNameErr = errors.New("tag name appears in both add and delete lists") + noTagModificationsErr = errors.New("at least one tag addition or deletion is required") ) diff --git a/core/upload_config_loader_test.go b/core/upload_config_loader_test.go index 76c0bed..7a9b6ef 100644 --- a/core/upload_config_loader_test.go +++ b/core/upload_config_loader_test.go @@ -221,6 +221,40 @@ func (this *UploadConfigLoaderFixture) TestValidateRemoteAddressPrefixIsNotNil() this.So(err, should.Resemble, nilRemoteAddressPrefixErr) } +func (this *UploadConfigLoaderFixture) TestValidateTagNameIsNotBlank() { + this.pkgConfig.Tags = []string{"stable", ""} + raw, _ := json.Marshal(this.pkgConfig.configure()) + this.storage.WriteFile("config.json", raw) + args := []string{"-json", "config.json"} + + _, err := this.loader.LoadConfig("upload", args) + + this.So(err, should.Resemble, blankTagNameErr) +} + +func (this *UploadConfigLoaderFixture) TestValidateTagNameIsNotReserved() { + this.pkgConfig.Tags = []string{"latest"} + raw, _ := json.Marshal(this.pkgConfig.configure()) + this.storage.WriteFile("config.json", raw) + args := []string{"-json", "config.json"} + + _, err := this.loader.LoadConfig("upload", args) + + this.So(err, should.Resemble, reservedTagNameErr) +} + +func (this *UploadConfigLoaderFixture) TestValidTagsAccepted() { + this.pkgConfig.Tags = []string{"stable", "experimental"} + raw, _ := json.Marshal(this.pkgConfig.configure()) + this.storage.WriteFile("config.json", raw) + args := []string{"-json", "config.json"} + + config, err := this.loader.LoadConfig("upload", args) + + this.So(err, should.BeNil) + this.So(config.PackageConfig.Tags, should.Resemble, []string{"stable", "experimental"}) +} + func (this *UploadConfigLoaderFixture) prepareValidJSONConfigFile() contracts.PackageConfig { packageConfig := this.pkgConfig.configure() raw, _ := json.Marshal(packageConfig) @@ -236,6 +270,7 @@ type FakePackageConfig struct { PackageName string PackageVersion string RemoteAddressPrefix *contracts.URL + Tags []string } func NewFakePackageConfig() *FakePackageConfig { @@ -256,6 +291,7 @@ func (this *FakePackageConfig) configure() contracts.PackageConfig { PackageName: this.PackageName, PackageVersion: this.PackageVersion, RemoteAddressPrefix: this.RemoteAddressPrefix, + Tags: this.Tags, } } diff --git a/transfer/tags.go b/transfer/tags.go new file mode 100644 index 0000000..4dbfe06 --- /dev/null +++ b/transfer/tags.go @@ -0,0 +1,138 @@ +package transfer + +import ( + "bytes" + "crypto/md5" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "github.com/smarty/satisfy/contracts" + "github.com/smarty/satisfy/core" + "github.com/smarty/satisfy/shell" +) + +type TagsApp struct { + config contracts.TagsConfig + client contracts.RemoteStorage +} + +func NewTagsApp(config contracts.TagsConfig) *TagsApp { + return &TagsApp{config: config} +} + +func (this *TagsApp) Run() { + if err := this.TryRun(); err != nil { + log.Fatal(err) + } +} + +func (this *TagsApp) TryRun() error { + this.buildRemoteStorageClient() + modification := this.config.Modification + + rootManifest, err := this.downloadRootManifest() + if err != nil { + return err + } + + err = this.verifyVersionsExist(modification.Add) + if err != nil { + return err + } + + this.logModifications(rootManifest, modification) + rootManifest.Tags = core.ApplyTagModifications(rootManifest.Tags, modification.Add, modification.Delete) + + err = this.uploadRootManifest(rootManifest) + if err != nil { + return err + } + + log.Printf("Tags updated for package %q", modification.PackageName) + return nil +} + +func (this *TagsApp) buildRemoteStorageClient() { + gcsClient := shell.NewGoogleCloudStorageClient(shell.NewHTTPClient(), this.config.GoogleCredentials, []int{http.StatusOK}) + this.client = core.NewRetryClient(gcsClient, this.config.MaxRetry, time.Sleep) +} + +func (this *TagsApp) downloadRootManifest() (manifest contracts.Manifest, err error) { + address := this.config.Modification.ComposeRootManifestAddress() + body, err := this.client.Download(address) + if contracts.IsNotFound(err) { + return contracts.Manifest{}, fmt.Errorf( + "no root manifest found for package %q at [%s] (has the package been uploaded?)", + this.config.Modification.PackageName, address.String()) + } + if err != nil { + return contracts.Manifest{}, fmt.Errorf("could not download root manifest: %w", err) + } + defer func() { _ = body.Close() }() + + if err = json.NewDecoder(body).Decode(&manifest); err != nil { + return contracts.Manifest{}, fmt.Errorf("could not decode root manifest: %w", err) + } + return manifest, nil +} + +// verifyVersionsExist guards against dangling tags by confirming each version +// to be tagged has a manifest on remote storage. No modifications are written +// unless every version checks out. +func (this *TagsApp) verifyVersionsExist(add []contracts.Tag) error { + verified := make(map[string]struct{}) + for _, tag := range add { + if _, done := verified[tag.Version]; done { + continue + } + address := this.config.Modification.ComposeVersionedManifestAddress(tag.Version) + _, err := this.client.Size(address) + if contracts.IsNotFound(err) { + return fmt.Errorf("cannot tag version %q as %q: no manifest found at [%s]", + tag.Version, tag.Name, address.String()) + } + if err != nil { + return fmt.Errorf("could not verify version %q exists: %w", tag.Version, err) + } + verified[tag.Version] = struct{}{} + } + return nil +} + +func (this *TagsApp) logModifications(rootManifest contracts.Manifest, modification contracts.TagModificationConfig) { + for _, tag := range modification.Add { + if existing, found := rootManifest.TagVersion(tag.Name); found { + log.Printf("Updating tag %q: %q -> %q", tag.Name, existing, tag.Version) + } else { + log.Printf("Adding tag %q -> %q", tag.Name, tag.Version) + } + } + for _, tag := range modification.Delete { + if _, found := rootManifest.TagVersion(tag.Name); found { + log.Printf("Deleting tag %q", tag.Name) + } else { + log.Printf("Tag %q does not exist; nothing to delete", tag.Name) + } + } +} + +func (this *TagsApp) uploadRootManifest(manifest contracts.Manifest) error { + buffer := new(bytes.Buffer) + hasher := md5.New() + encoder := json.NewEncoder(io.MultiWriter(buffer, hasher)) + encoder.SetIndent("", " ") + if err := encoder.Encode(manifest); err != nil { + return err + } + return this.client.Upload(contracts.UploadRequest{ + RemoteAddress: this.config.Modification.ComposeRootManifestAddress(), + Body: bytes.NewReader(buffer.Bytes()), + Size: int64(buffer.Len()), + ContentType: "application/json", + Checksum: hasher.Sum(nil), + }) +} diff --git a/transfer/upload.go b/transfer/upload.go index 38c6ac6..847c969 100644 --- a/transfer/upload.go +++ b/transfer/upload.go @@ -71,8 +71,37 @@ func (this *UploadApp) Run() { this.deleteLocalArchiveFile() log.Println("Uploading the manifest...") - this.upload(this.buildManifestUploadRequest(this.packageConfig.ComposeRemoteAddress(contracts.RemoteManifestFilename))) - this.upload(this.buildManifestUploadRequest(this.packageConfig.ComposeLatestManifestRemoteAddress())) + this.upload(this.buildManifestUploadRequest(this.manifest, this.packageConfig.ComposeRemoteAddress(contracts.RemoteManifestFilename))) + this.upload(this.buildManifestUploadRequest(this.buildRootManifest(), this.packageConfig.ComposeLatestManifestRemoteAddress())) +} + +// buildRootManifest carries the tag listing forward from the existing root +// manifest and points any tags named in the upload config at the version being +// uploaded. The versioned manifest never includes tags. +func (this *UploadApp) buildRootManifest() contracts.Manifest { + rootManifest := this.manifest + rootManifest.Tags = core.MergeTags(this.downloadExistingTags(), this.packageConfig.Tags, this.packageConfig.PackageVersion) + for _, name := range this.packageConfig.Tags { + log.Printf("Tagging version %q as %q", this.packageConfig.PackageVersion, name) + } + return rootManifest +} + +func (this *UploadApp) downloadExistingTags() []contracts.Tag { + body, err := this.client.Download(this.packageConfig.ComposeLatestManifestRemoteAddress()) + if contracts.IsNotFound(err) { + return nil // first upload of this package + } + if err != nil { + log.Fatal("Could not download existing root manifest (needed to preserve tags): ", err) + } + defer func() { _ = body.Close() }() + + var existing contracts.Manifest + if err = json.NewDecoder(body).Decode(&existing); err != nil { + log.Fatal("Could not decode existing root manifest (needed to preserve tags): ", err) + } + return existing.Tags } func (this *UploadApp) buildArchiveUploadRequest() contracts.UploadRequest { @@ -157,8 +186,8 @@ var contentType = map[string]string{ "zip": "application/zip", } -func (this *UploadApp) buildManifestUploadRequest(remoteAddress url.URL) contracts.UploadRequest { - buffer := this.writeManifestToBuffer() +func (this *UploadApp) buildManifestUploadRequest(manifest contracts.Manifest, remoteAddress url.URL) contracts.UploadRequest { + buffer := this.writeManifestToBuffer(manifest) return contracts.UploadRequest{ RemoteAddress: remoteAddress, Body: bytes.NewReader(buffer.Bytes()), @@ -221,13 +250,13 @@ func (this *UploadApp) upload(request contracts.UploadRequest) { } } -func (this *UploadApp) writeManifestToBuffer() *bytes.Buffer { +func (this *UploadApp) writeManifestToBuffer(manifest contracts.Manifest) *bytes.Buffer { buffer := new(bytes.Buffer) this.hasher.Reset() writer := io.MultiWriter(buffer, this.hasher) encoder := json.NewEncoder(writer) encoder.SetIndent("", " ") - _ = encoder.Encode(this.manifest) + _ = encoder.Encode(manifest) return buffer } From 299c830e9ea8595189b74d8341dcabd790c619e2 Mon Sep 17 00:00:00 2001 From: Adam Charlton Date: Mon, 15 Jun 2026 12:01:21 -0600 Subject: [PATCH 2/2] added check for manifest, instead of full downloading, for version existence --- contracts/installation.go | 1 + core/dependency_resolver.go | 10 +++++----- core/dependency_resolver_test.go | 27 +++++++++++++++++++++++++++ core/installer.go | 13 +++++++++++++ 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/contracts/installation.go b/contracts/installation.go index 66a2c80..a58b37b 100644 --- a/contracts/installation.go +++ b/contracts/installation.go @@ -14,6 +14,7 @@ type IntegrityCheck interface { type PackageInstaller interface { DownloadManifest(remoteAddress url.URL) (manifest Manifest, err error) + ManifestExists(remoteAddress url.URL) (exists bool, err error) InstallManifest(request InstallationRequest) (manifest Manifest, err error) InstallPackage(manifest Manifest, request InstallationRequest) error } diff --git a/core/dependency_resolver.go b/core/dependency_resolver.go index fe299ea..5509808 100644 --- a/core/dependency_resolver.go +++ b/core/dependency_resolver.go @@ -125,12 +125,12 @@ func (this *DependencyResolver) isInstalledCorrectly(localManifest contracts.Man // version path takes precedence over a tag of the same name; only when no such // version exists is the requested string resolved against the root manifest tags. func (this *DependencyResolver) localManifestMatchesRequestedTag(localManifest contracts.Manifest) (bool, error) { - _, err := this.packageInstaller.DownloadManifest(this.dependency.ComposeRemoteAddress(contracts.RemoteManifestFilename)) - if err == nil { - return false, nil + exists, err := this.packageInstaller.ManifestExists(this.dependency.ComposeRemoteAddress(contracts.RemoteManifestFilename)) + if err != nil { + return false, fmt.Errorf("failed to check for manifest of %s: %w", this.dependency.Title(), err) } - if !contracts.IsNotFound(err) { - return false, fmt.Errorf("failed to download manifest for %s: %w", this.dependency.Title(), err) + if exists { + return false, nil } version, err := this.resolveTag() diff --git a/core/dependency_resolver_test.go b/core/dependency_resolver_test.go index 29efa33..569702f 100644 --- a/core/dependency_resolver_test.go +++ b/core/dependency_resolver_test.go @@ -318,6 +318,23 @@ func (this *DependencyResolverFixture) TestLiteralVersionShadowsTagOfSameName() this.assertNewPackageInstalled("B/C", "stable") } +func (this *DependencyResolverFixture) TestVersionProbeFailurePreservesLocalInstallation() { + probeErr := errors.New("remote storage unavailable") + this.prepareLocalPackageAndManifest(this.dependency.PackageName, "D") + this.dependency.PackageVersion = "E" + this.packageInstaller.errsByAddress = map[string]error{ + "gcs://A/B/C/E/manifest.json": probeErr, + } + + err := this.Resolve() + + this.So(errors.Is(err, probeErr), should.BeTrue) + this.So(this.packageInstaller.installPackageCounter, should.Equal, 0) + this.So(this.fileSystem.fileSystem, should.ContainKey, "local/contents1") + this.So(this.fileSystem.fileSystem, should.ContainKey, "local/contents2") + this.So(this.fileSystem.fileSystem, should.ContainKey, "local/contents3") +} + func (this *DependencyResolverFixture) TestTagResolutionFailsWhenRootManifestUnavailable() { rootErr := errors.New("root manifest unavailable") this.dependency.PackageVersion = "stable" @@ -411,6 +428,16 @@ func (this *FakePackageInstaller) DownloadManifest(address url.URL) (manifest co return this.remoteLatest, this.downloadError } +func (this *FakePackageInstaller) ManifestExists(address url.URL) (bool, error) { + if err, found := this.errsByAddress[address.String()]; found { + if contracts.IsNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} + func (this *FakePackageInstaller) InstallManifest(request contracts.InstallationRequest) (manifest contracts.Manifest, err error) { this.installManifestCounter++ this.manifestRequest = request diff --git a/core/installer.go b/core/installer.go index 9b57763..3ca1795 100644 --- a/core/installer.go +++ b/core/installer.go @@ -56,6 +56,19 @@ func (this *PackageInstaller) DownloadManifest(remoteAddress url.URL) (manifest return manifest, err } +// ManifestExists performs a HEAD request, sparing the cost of downloading the +// manifest body when only its presence matters. +func (this *PackageInstaller) ManifestExists(remoteAddress url.URL) (bool, error) { + _, err := this.downloader.Size(remoteAddress) + if contracts.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + func (this *PackageInstaller) InstallManifest(request contracts.InstallationRequest) (manifest contracts.Manifest, err error) { manifest, err = this.DownloadManifest(request.RemoteAddress) if err != nil {