// Package artifacts speaks to the mesh's artifact store over its own door. // // Only what the mesh needs that nothing else does: letting go of something it put there // (novox/hq ADR 0189, issue 108). Pushing is the builder's, through the container runtime; reading // is every machine's, through its runtime. This is the one operation that belongs to the thing // holding the records, because it is the only one that is a decision rather than a transfer. package artifacts import ( "context" "fmt" "net/http" "strings" "time" "github.com/novox/mesh-controller/internal/catalogue" ) // Store is the artifact store at an address, as this machine reaches it. type Store struct { // Address is `host:port` — the store as the caller reaches it now, composed and never // recorded (novox/hq 04-ISSUES/102). Address string // HTTP is the client used; nil is a client with a modest timeout. HTTP *http.Client } // Gone is the answer when the store does not hold it: the outcome wanted, already true. var Gone = fmt.Errorf("the store does not hold it") // LetGo asks the store to drop one artifact the mesh recorded making. // // Takes a reference as the mesh records it — `artifact-store:///@sha256:…` for // an image, `…/blobs/sha256:…` for an archive — because that is the identity every record uses, // and composes the address here at the moment of use. // // Returns Gone when the store answers that it does not have it. That is not a failure: the sweep // wants the artifact absent, and it is. It is distinguished from success only so a caller can say // which of the two happened. func (s Store) LetGo(ctx context.Context, reference string) error { path, kept := catalogue.InArtifactStore(reference) if !kept { // Nothing the mesh put in its own store. Refused rather than attempted: composing a // delete for a reference of unknown shape is how a sweep reaches something that is not // the mesh's. return fmt.Errorf("%s is not a reference into the mesh's artifact store", reference) } if s.Address == "" { return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference) } repository, kind, digest, err := split(path) if err != nil { return err } url := "http://" + s.Address + "/v2/" + repository + "/" + kind + "/" + digest request, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) if err != nil { return err } client := s.HTTP if client == nil { client = &http.Client{Timeout: 30 * time.Second} } response, err := client.Do(request) if err != nil { return err } defer response.Body.Close() switch response.StatusCode { case http.StatusAccepted, http.StatusOK, http.StatusNoContent: return nil case http.StatusNotFound: return Gone case http.StatusMethodNotAllowed: // The registry was started without deletion enabled. Said plainly, because the remedy is // a setting on the store's module and not anything about this artifact. return fmt.Errorf( "the artifact store refuses deletion: its server was started without it enabled "+ "(REGISTRY_STORAGE_DELETE_ENABLED), so nothing can be collected until the store "+ "module is applied again (novox/hq ADR 0189). Asking about %s", reference) default: return fmt.Errorf("the artifact store answered %s for %s", response.Status, reference) } } // split reads a recorded path into the repository, which endpoint names the thing, and the digest. // // Two shapes, which are the two the mesh records: `@sha256:` is a manifest, and // `/blobs/sha256:` is a blob. func split(path string) (repository, kind, digest string, err error) { if before, after, ok := strings.Cut(path, "@sha256:"); ok { return before, "manifests", "sha256:" + after, nil } if before, after, ok := strings.Cut(path, "/blobs/sha256:"); ok { return before, "blobs", "sha256:" + after, nil } return "", "", "", fmt.Errorf("%q names nothing the store holds by digest", path) }