diff --git a/Godeps/_workspace/src/github.com/docker/distribution/registry.go b/Godeps/_workspace/src/github.com/docker/distribution/registry.go index c5d84a0faca2..dcff2a946eca 100644 --- a/Godeps/_workspace/src/github.com/docker/distribution/registry.go +++ b/Godeps/_workspace/src/github.com/docker/distribution/registry.go @@ -39,6 +39,8 @@ type Namespace interface { // registry may or may not have the repository but should always return a // reference. Repository(ctx context.Context, name string) (Repository, error) + + Blobs() BlobService } // Repository is a named collection of manifests and layers. @@ -108,6 +110,9 @@ type LayerService interface { // Fetch the layer identifed by TarSum. Fetch(digest digest.Digest) (Layer, error) + // Delete unlinks the layer from a Repository. + Delete(dgst digest.Digest) error + // Upload begins a layer upload to repository identified by name, // returning a handle. Upload() (LayerUpload, error) @@ -173,6 +178,10 @@ type SignatureService interface { Put(dgst digest.Digest, signatures ...[]byte) error } +type BlobService interface { + Delete(dgst digest.Digest) error +} + // Descriptor describes targeted content. Used in conjunction with a blob // store, a descriptor can be used to fetch, store and target any kind of // blob. The struct also describes the wire protocol format. Fields should diff --git a/Godeps/_workspace/src/github.com/docker/distribution/registry/handlers/app.go b/Godeps/_workspace/src/github.com/docker/distribution/registry/handlers/app.go index 28940c8e1d40..2664a43eb24a 100644 --- a/Godeps/_workspace/src/github.com/docker/distribution/registry/handlers/app.go +++ b/Godeps/_workspace/src/github.com/docker/distribution/registry/handlers/app.go @@ -133,18 +133,42 @@ func NewApp(ctx context.Context, configuration configuration.Configuration) *App return app } +func (app *App) Registry() distribution.Namespace { + return app.registry +} + +type customAccessRecordsFunc func(*http.Request) []auth.Access + +func NoCustomAccessRecords(*http.Request) []auth.Access { + return []auth.Access{} +} + +func NameNotRequired(*http.Request) bool { + return false +} + +func NameRequired(*http.Request) bool { + return true +} + // register a handler with the application, by route name. The handler will be // passed through the application filters and context will be constructed at // request time. func (app *App) register(routeName string, dispatch dispatchFunc) { + app.RegisterRoute(app.router.GetRoute(routeName), dispatch, app.nameRequired, NoCustomAccessRecords) +} +func (app *App) RegisterRoute(route *mux.Route, dispatch dispatchFunc, nameRequired nameRequiredFunc, accessRecords customAccessRecordsFunc) { // TODO(stevvooe): This odd dispatcher/route registration is by-product of // some limitations in the gorilla/mux router. We are using it to keep // routing consistent between the client and server, but we may want to // replace it with manual routing and structure-based dispatch for better // control over the request execution. + route.Handler(app.dispatcher(dispatch, nameRequired, accessRecords)) +} - app.router.GetRoute(routeName).Handler(app.dispatcher(dispatch)) +func (app *App) NewRoute() *mux.Route { + return app.router.NewRoute() } // configureEvents prepares the event sink for action. @@ -308,11 +332,11 @@ type dispatchFunc func(ctx *Context, r *http.Request) http.Handler // dispatcher returns a handler that constructs a request specific context and // handler, using the dispatch factory function. -func (app *App) dispatcher(dispatch dispatchFunc) http.Handler { +func (app *App) dispatcher(dispatch dispatchFunc, nameRequired nameRequiredFunc, accessRecords customAccessRecordsFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { context := app.context(w, r) - if err := app.authorized(w, r, context); err != nil { + if err := app.authorized(w, r, context, nameRequired, accessRecords(r)); err != nil { ctxu.GetLogger(context).Errorf("error authorizing context: %v", err) return } @@ -320,7 +344,7 @@ func (app *App) dispatcher(dispatch dispatchFunc) http.Handler { // Add username to request logging context.Context = ctxu.WithLogger(context.Context, ctxu.GetLogger(context.Context, "auth.user.name")) - if app.nameRequired(r) { + if nameRequired(r) { repository, err := app.registry.Repository(context, getName(context)) if err != nil { @@ -393,7 +417,7 @@ func (app *App) context(w http.ResponseWriter, r *http.Request) *Context { // authorized checks if the request can proceed with access to the requested // repository. If it succeeds, the context may access the requested // repository. An error will be returned if access is not available. -func (app *App) authorized(w http.ResponseWriter, r *http.Request, context *Context) error { +func (app *App) authorized(w http.ResponseWriter, r *http.Request, context *Context, nameRequired nameRequiredFunc, customAccessRecords []auth.Access) error { ctxu.GetLogger(context).Debug("authorizing request") repo := getName(context) @@ -402,12 +426,15 @@ func (app *App) authorized(w http.ResponseWriter, r *http.Request, context *Cont } var accessRecords []auth.Access + accessRecords = append(accessRecords, customAccessRecords...) if repo != "" { accessRecords = appendAccessRecords(accessRecords, r.Method, repo) - } else { + } + + if len(accessRecords) == 0 { // Only allow the name not to be set on the base route. - if app.nameRequired(r) { + if nameRequired(r) { // For this to be properly secured, repo must always be set for a // resource that may make a modification. The only condition under // which name is not set and we still allow access is when the @@ -464,6 +491,8 @@ func (app *App) eventBridge(ctx *Context, r *http.Request) notifications.Listene return notifications.NewBridge(ctx.urlBuilder, app.events.source, actor, request, app.events.sink) } +type nameRequiredFunc func(*http.Request) bool + // nameRequired returns true if the route requires a name. func (app *App) nameRequired(r *http.Request) bool { route := mux.CurrentRoute(r) diff --git a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/blobstore.go b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/blobstore.go index 8bab2f5e1d76..2a0d449accaf 100644 --- a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/blobstore.go +++ b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/blobstore.go @@ -2,7 +2,9 @@ package storage import ( "fmt" + "strings" + "github.com/docker/distribution" ctxu "github.com/docker/distribution/context" "github.com/docker/distribution/digest" storagedriver "github.com/docker/distribution/registry/storage/driver" @@ -23,6 +25,30 @@ type blobStore struct { ctx context.Context } +var _ distribution.BlobService = &blobStore{} + +func (bs *blobStore) Delete(dgst digest.Digest) error { + found, err := bs.exists(dgst) + if err != nil { + return err + } + + if !found { + // TODO if the blob doesn't exist, should this be an error? + return nil + } + + path, err := bs.path(dgst) + + if err != nil { + return err + } + + path = strings.TrimSuffix(path, "/data") + + return bs.driver.Delete(path) +} + // exists reports whether or not the path exists. If the driver returns error // other than storagedriver.PathNotFound, an error may be returned. func (bs *blobStore) exists(dgst digest.Digest) (bool, error) { diff --git a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/cache/cache.go b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/cache/cache.go index a21cefd5745e..dcc79d8d53fd 100644 --- a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/cache/cache.go +++ b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/cache/cache.go @@ -38,6 +38,8 @@ type LayerInfoCache interface { // Add includes the layer in the given repository cache. Add(ctx context.Context, repo string, dgst digest.Digest) error + Delete(ctx context.Context, repo string, dgst digest.Digest) error + // Meta provides the location of the layer on the backend and its size. Membership of a // repository should be tested before using the result, if required. Meta(ctx context.Context, dgst digest.Digest) (LayerMeta, error) @@ -77,6 +79,18 @@ func (b *base) Add(ctx context.Context, repo string, dgst digest.Digest) error { return b.LayerInfoCache.Add(ctx, repo, dgst) } +func (b *base) Delete(ctx context.Context, repo string, dgst digest.Digest) error { + if repo == "" { + return fmt.Errorf("cache: cannot delete empty repository name") + } + + if dgst == "" { + return fmt.Errorf("cache: cannot delete empty digest") + } + + return b.LayerInfoCache.Delete(ctx, repo, dgst) +} + func (b *base) Meta(ctx context.Context, dgst digest.Digest) (LayerMeta, error) { if dgst == "" { return LayerMeta{}, fmt.Errorf("cache: cannot get meta for empty digest") diff --git a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/cache/memory.go b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/cache/memory.go index 6d949792502c..63b0dcc0d789 100644 --- a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/cache/memory.go +++ b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/cache/memory.go @@ -43,6 +43,15 @@ func (ilic *inmemoryLayerInfoCache) Add(ctx context.Context, repo string, dgst d return nil } +func (ilic *inmemoryLayerInfoCache) Delete(ctx context.Context, repo string, dgst digest.Digest) error { + members, ok := ilic.membership[repo] + if !ok { + return nil + } + delete(members, dgst) + return nil +} + // Meta retrieves the layer meta data from the redis hash, returning // ErrUnknownLayer if not found. func (ilic *inmemoryLayerInfoCache) Meta(ctx context.Context, dgst digest.Digest) (LayerMeta, error) { diff --git a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/cache/redis.go b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/cache/redis.go index 6b8f7679abe7..eba0a8af2849 100644 --- a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/cache/redis.go +++ b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/cache/redis.go @@ -53,6 +53,11 @@ func (rlic *redisLayerInfoCache) Add(ctx context.Context, repo string, dgst dige return err } +func (rlic *redisLayerInfoCache) Delete(ctx context.Context, repo string, dgst digest.Digest) error { + //TODO + return nil +} + // Meta retrieves the layer meta data from the redis hash, returning // ErrUnknownLayer if not found. func (rlic *redisLayerInfoCache) Meta(ctx context.Context, dgst digest.Digest) (LayerMeta, error) { diff --git a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/layercache.go b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/layercache.go index b9732f203eb5..3d7949f4c1ca 100644 --- a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/layercache.go +++ b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/layercache.go @@ -128,6 +128,14 @@ fallback: return layer, err } +func (lc *cachedLayerService) Delete(dgst digest.Digest) error { + ctxu.GetLogger(lc.ctx).Debugf("(*layerInfoCache).Delete(%q)", dgst) + if err := lc.cache.Delete(lc.ctx, lc.repository.Name(), dgst); err != nil { + ctxu.GetLogger(lc.ctx).Errorf("error deleting layer link from cache; repo=%s, layer=%s: %v", lc.repository.Name(), dgst, err) + } + return lc.LayerService.Delete(dgst) +} + // extractLayerInfo pulls the layerInfo from the layer, attempting to get the // path information from either the concrete object or by resolving the // primary blob store path. diff --git a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/layerstore.go b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/layerstore.go index 1c7428a9f37a..802753633fa2 100644 --- a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/layerstore.go +++ b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/layerstore.go @@ -1,6 +1,7 @@ package storage import ( + "strings" "time" "code.google.com/p/go-uuid/uuid" @@ -52,6 +53,17 @@ func (ls *layerStore) Fetch(dgst digest.Digest) (distribution.Layer, error) { }, nil } +func (ls *layerStore) Delete(dgst digest.Digest) error { + lp, err := ls.linkPath(dgst) + if err != nil { + return err + } + + lp = strings.TrimSuffix(lp, "/link") + + return ls.repository.driver.Delete(lp) +} + // Upload begins a layer upload, returning a handle. If the layer upload // is already in progress or the layer has already been uploaded, this // will return an error. @@ -150,9 +162,13 @@ func (ls *layerStore) newLayerUpload(uuid, path string, startedAt time.Time) (di return lw, nil } +func (ls *layerStore) linkPath(dgst digest.Digest) (string, error) { + return ls.repository.registry.pm.path(layerLinkPathSpec{name: ls.repository.Name(), digest: dgst}) +} + func (ls *layerStore) path(dgst digest.Digest) (string, error) { // We must traverse this path through the link to enforce ownership. - layerLinkPath, err := ls.repository.registry.pm.path(layerLinkPathSpec{name: ls.repository.Name(), digest: dgst}) + layerLinkPath, err := ls.linkPath(dgst) if err != nil { return "", err } diff --git a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/manifeststore.go b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/manifeststore.go index d83c48b6e551..0a554ad3ad47 100644 --- a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/manifeststore.go +++ b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/manifeststore.go @@ -55,8 +55,8 @@ func (ms *manifestStore) Put(ctx context.Context, manifest *manifest.SignedManif // Delete removes the revision of the specified manfiest. func (ms *manifestStore) Delete(ctx context.Context, dgst digest.Digest) error { - ctxu.GetLogger(ms.repository.ctx).Debug("(*manifestStore).Delete - unsupported") - return fmt.Errorf("deletion of manifests not supported") + ctxu.GetLogger(ms.repository.ctx).Debug("(*manifestStore).Delete") + return ms.revisionStore.delete(dgst) } func (ms *manifestStore) Tags(ctx context.Context) ([]string, error) { diff --git a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/registry.go b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/registry.go index 1126db457200..919fd7b70525 100644 --- a/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/registry.go +++ b/Godeps/_workspace/src/github.com/docker/distribution/registry/storage/registry.go @@ -121,3 +121,7 @@ func (repo *repository) Signatures() distribution.SignatureService { repository: repo, } } + +func (reg *registry) Blobs() distribution.BlobService { + return reg.blobStore +} diff --git a/pkg/api/graph/graph.go b/pkg/api/graph/graph.go index 6a74172e92b8..b6e13aad4022 100644 --- a/pkg/api/graph/graph.go +++ b/pkg/api/graph/graph.go @@ -23,6 +23,10 @@ type uniqueNamer interface { UniqueName() string } +type NodeFinder interface { + Find(name UniqueName) graph.Node +} + // UniqueNodeInitializer is a graph that allows nodes with a unique name to be added without duplication. // If the node is newly added, true will be returned. type UniqueNodeInitializer interface { @@ -44,6 +48,7 @@ type MutableUniqueGraph interface { graph.Mutable MutableDirectedEdge UniqueNodeInitializer + NodeFinder } type Edge struct { @@ -294,6 +299,13 @@ func (g uniqueNamedGraph) FindOrCreate(name UniqueName, fn NodeInitializerFunc) return node, false } +func (g uniqueNamedGraph) Find(name UniqueName) graph.Node { + if node, ok := g.names[name]; ok { + return node + } + return nil +} + type typedGraph struct{} type stringer interface { diff --git a/pkg/api/graph/graph_test.go b/pkg/api/graph/graph_test.go index 4e49f36d78f9..a9caf2ca3bb8 100644 --- a/pkg/api/graph/graph_test.go +++ b/pkg/api/graph/graph_test.go @@ -188,7 +188,8 @@ func TestGraph(t *testing.T) { } bc++ case *image.ImageStream: - if g.Kind(node) != ImageStreamGraphKind { + // TODO resolve this check for 2 kinds, since both have the same object type + if g.Kind(node) != ImageStreamGraphKind && g.Kind(node) != ImageStreamTagGraphKind { t.Fatalf("unexpected kind: %v", g.Kind(node)) } ir++ diff --git a/pkg/api/graph/types.go b/pkg/api/graph/types.go index e0f1336f873a..c142687349a7 100644 --- a/pkg/api/graph/types.go +++ b/pkg/api/graph/types.go @@ -20,12 +20,18 @@ import ( const ( UnknownGraphKind = iota - ImageStreamGraphKind + ImageStreamTagGraphKind DockerRepositoryGraphKind BuildConfigGraphKind DeploymentConfigGraphKind SourceRepositoryGraphKind ServiceGraphKind + ImageGraphKind + PodGraphKind + ImageStreamGraphKind + ReplicationControllerGraphKind + ImageLayerGraphKind + BuildGraphKind ) const ( UnknownGraphEdgeKind = iota @@ -36,6 +42,9 @@ const ( BuildOutputGraphEdgeKind UsedInDeploymentGraphEdgeKind ExposedThroughServiceGraphEdgeKind + ReferencedImageGraphEdgeKind + WeakReferencedImageGraphEdgeKind + ReferencedImageLayerGraphEdgeKind ) type ServiceNode struct { @@ -119,7 +128,7 @@ func (n ImageStreamTagNode) String() string { } func (*ImageStreamTagNode) Kind() int { - return ImageStreamGraphKind + return ImageStreamTagGraphKind } type DockerImageRepositoryNode struct { @@ -159,6 +168,50 @@ func (SourceRepositoryNode) Kind() int { return SourceRepositoryGraphKind } +type ImageNode struct { + Node + Image *image.Image +} + +func (n ImageNode) Object() interface{} { + return n.Image +} + +func (n ImageNode) String() string { + return fmt.Sprintf("", n.Image.Name) +} + +func (*ImageNode) Kind() int { + return ImageGraphKind +} + +func Image(g MutableUniqueGraph, img *image.Image) graph.Node { + return EnsureUnique(g, + UniqueName(fmt.Sprintf("%d|%s", ImageGraphKind, img.Name)), + func(node Node) graph.Node { + return &ImageNode{node, img} + }, + ) +} + +func FindImage(g MutableUniqueGraph, imageName string) graph.Node { + return g.Find(UniqueName(fmt.Sprintf("%d|%s", ImageGraphKind, imageName))) +} + +type PodNode struct { + Node + Pod *kapi.Pod +} + +func Pod(g MutableUniqueGraph, pod *kapi.Pod) graph.Node { + return EnsureUnique(g, + UniqueName(fmt.Sprintf("%d|%s/%s", PodGraphKind, pod.Namespace, pod.Name)), + func(node Node) graph.Node { + return &PodNode{node, pod} + }, + ) +} + // Service adds the provided service to the graph if it does not already exist. It does not // link the service to covered nodes (that is a separate method). func Service(g MutableUniqueGraph, svc *kapi.Service) graph.Node { @@ -218,7 +271,7 @@ func SourceRepository(g MutableUniqueGraph, source build.BuildSource) (graph.Nod ), true } -// ImageStreamTag adds a graph node for the specific tag in an Image Repository if it +// ImageStreamTag adds a graph node for the specific tag in an Image Stream if it // does not already exist. func ImageStreamTag(g MutableUniqueGraph, namespace, name, tag string) graph.Node { if len(tag) == 0 { @@ -227,7 +280,7 @@ func ImageStreamTag(g MutableUniqueGraph, namespace, name, tag string) graph.Nod if strings.Contains(name, ":") { panic(name) } - uname := UniqueName(fmt.Sprintf("%d|%s/%s:%s", ImageStreamGraphKind, namespace, name, tag)) + uname := UniqueName(fmt.Sprintf("%d|%s/%s:%s", ImageStreamTagGraphKind, namespace, name, tag)) return EnsureUnique(g, uname, func(node Node) graph.Node { @@ -273,7 +326,7 @@ func BuildConfig(g MutableUniqueGraph, config *build.BuildConfig) graph.Node { g.AddEdge(in, node, BuildInputGraphEdgeKind) } - from := buildutil.GetImageStreamForStrategy(config) + from := buildutil.GetImageStreamForStrategy(config.Parameters.Strategy) if from != nil { switch from.Kind { case "DockerImage": @@ -433,3 +486,119 @@ func defaultNamespace(value, defaultValue string) string { } return value } + +type ImageStreamNode struct { + Node + *image.ImageStream +} + +func (n ImageStreamNode) Object() interface{} { + return n.ImageStream +} + +func (n ImageStreamNode) String() string { + return fmt.Sprintf("", n.Namespace, n.Name) +} + +func (*ImageStreamNode) Kind() int { + return ImageStreamGraphKind +} + +func imageStreamName(stream *image.ImageStream) UniqueName { + return UniqueName(fmt.Sprintf("%d|%s", ImageStreamGraphKind, stream.Status.DockerImageRepository)) +} + +// ImageStream adds a graph node for the Image Stream if it does not already exist. +func ImageStream(g MutableUniqueGraph, stream *image.ImageStream) graph.Node { + return EnsureUnique(g, + imageStreamName(stream), + func(node Node) graph.Node { + return &ImageStreamNode{node, stream} + }, + ) +} + +func FindImageStream(g MutableUniqueGraph, stream *image.ImageStream) graph.Node { + return g.Find(imageStreamName(stream)) +} + +type ReplicationControllerNode struct { + Node + *kapi.ReplicationController +} + +func (n ReplicationControllerNode) Object() interface{} { + return n.ReplicationController +} + +func (n ReplicationControllerNode) String() string { + return fmt.Sprintf("", n.Namespace, n.Name) +} + +func (*ReplicationControllerNode) Kind() int { + return ReplicationControllerGraphKind +} + +// ReplicationController adds a graph node for the ReplicationController if it does not already exist. +func ReplicationController(g MutableUniqueGraph, rc *kapi.ReplicationController) graph.Node { + return EnsureUnique(g, + UniqueName(fmt.Sprintf("%d|%s/%s", ReplicationControllerGraphKind, rc.Namespace, rc.Name)), + func(node Node) graph.Node { + return &ReplicationControllerNode{node, rc} + }, + ) +} + +type ImageLayerNode struct { + Node + Layer string +} + +func (n ImageLayerNode) Object() interface{} { + return n.Layer +} + +func (n ImageLayerNode) String() string { + return fmt.Sprintf("", n.Layer) +} + +func (*ImageLayerNode) Kind() int { + return ImageLayerGraphKind +} + +// ImageLayer adds a graph node for the layer if it does not already exist. +func ImageLayer(g MutableUniqueGraph, layer string) graph.Node { + return EnsureUnique(g, + UniqueName(fmt.Sprintf("%d|%s", ImageLayerGraphKind, layer)), + func(node Node) graph.Node { + return &ImageLayerNode{node, layer} + }, + ) +} + +type BuildNode struct { + Node + Build *build.Build +} + +func (n BuildNode) Object() interface{} { + return n.Build +} + +func (n BuildNode) String() string { + return fmt.Sprintf("", n.Build.Namespace, n.Build.Name) +} + +func (*BuildNode) Kind() int { + return BuildGraphKind +} + +// Build adds a graph node for the build if it does not already exist. +func Build(g MutableUniqueGraph, build *build.Build) graph.Node { + return EnsureUnique(g, + UniqueName(fmt.Sprintf("%d|%s/%s", BuildGraphKind, build.Namespace, build.Name)), + func(node Node) graph.Node { + return &BuildNode{node, build} + }, + ) +} diff --git a/pkg/build/controller/image_change_controller.go b/pkg/build/controller/image_change_controller.go index 90f292e8e5bc..e106b04db2db 100644 --- a/pkg/build/controller/image_change_controller.go +++ b/pkg/build/controller/image_change_controller.go @@ -57,7 +57,7 @@ func (c *ImageChangeController) HandleImageRepo(repo *imageapi.ImageStream) erro } originalConfig := obj.(*buildapi.BuildConfig) - from := buildutil.GetImageStreamForStrategy(config) + from := buildutil.GetImageStreamForStrategy(config.Parameters.Strategy) if from == nil || from.Kind != "ImageStreamTag" { continue } diff --git a/pkg/build/util/util.go b/pkg/build/util/util.go index 4584040490fd..f91947c7f3ea 100644 --- a/pkg/build/util/util.go +++ b/pkg/build/util/util.go @@ -13,15 +13,15 @@ func GetBuildPodName(build *buildapi.Build) string { } // GetImageStreamForStrategy returns the ImageStream[Tag/Image] ObjectReference associated -// with the BuildStrategy of a BuildConfig. -func GetImageStreamForStrategy(config *buildapi.BuildConfig) *kapi.ObjectReference { - switch config.Parameters.Strategy.Type { +// with the BuildStrategy. +func GetImageStreamForStrategy(strategy buildapi.BuildStrategy) *kapi.ObjectReference { + switch strategy.Type { case buildapi.SourceBuildStrategyType: - return config.Parameters.Strategy.SourceStrategy.From + return strategy.SourceStrategy.From case buildapi.DockerBuildStrategyType: - return config.Parameters.Strategy.DockerStrategy.From + return strategy.DockerStrategy.From case buildapi.CustomBuildStrategyType: - return config.Parameters.Strategy.CustomStrategy.From + return strategy.CustomStrategy.From default: return nil } diff --git a/pkg/client/fake_images.go b/pkg/client/fake_images.go index 4cac86da49fd..23a9272c8f4e 100644 --- a/pkg/client/fake_images.go +++ b/pkg/client/fake_images.go @@ -32,6 +32,6 @@ func (c *FakeImages) Create(image *imageapi.Image) (*imageapi.Image, error) { } func (c *FakeImages) Delete(name string) error { - c.Fake.Actions = append(c.Fake.Actions, FakeAction{Action: "delete-image", Value: name}) - return nil + _, err := c.Fake.Invokes(FakeAction{Action: "delete-image", Value: name}, nil) + return err } diff --git a/pkg/client/fake_imagestreams.go b/pkg/client/fake_imagestreams.go index cf4fd9c0ddcb..879bf2c6a49e 100644 --- a/pkg/client/fake_imagestreams.go +++ b/pkg/client/fake_imagestreams.go @@ -28,13 +28,13 @@ func (c *FakeImageStreams) Get(name string) (*imageapi.ImageStream, error) { return obj.(*imageapi.ImageStream), err } -func (c *FakeImageStreams) Create(repo *imageapi.ImageStream) (*imageapi.ImageStream, error) { +func (c *FakeImageStreams) Create(stream *imageapi.ImageStream) (*imageapi.ImageStream, error) { obj, err := c.Fake.Invokes(FakeAction{Action: "create-imagestream"}, &imageapi.ImageStream{}) return obj.(*imageapi.ImageStream), err } -func (c *FakeImageStreams) Update(repo *imageapi.ImageStream) (*imageapi.ImageStream, error) { - obj, err := c.Fake.Invokes(FakeAction{Action: "update-imagestream"}, &imageapi.ImageStream{}) +func (c *FakeImageStreams) Update(stream *imageapi.ImageStream) (*imageapi.ImageStream, error) { + obj, err := c.Fake.Invokes(FakeAction{Action: "update-imagestream", Value: stream}, stream) return obj.(*imageapi.ImageStream), err } @@ -47,3 +47,8 @@ func (c *FakeImageStreams) Watch(label labels.Selector, field fields.Selector, r c.Fake.Actions = append(c.Fake.Actions, FakeAction{Action: "watch-imagestreams"}) return nil, nil } + +func (c *FakeImageStreams) UpdateStatus(stream *imageapi.ImageStream) (result *imageapi.ImageStream, err error) { + obj, err := c.Fake.Invokes(FakeAction{Action: "update-status-imagestream", Value: stream}, stream) + return obj.(*imageapi.ImageStream), err +} diff --git a/pkg/client/imagestreams.go b/pkg/client/imagestreams.go index 43ca3b04ab31..917af5451a14 100644 --- a/pkg/client/imagestreams.go +++ b/pkg/client/imagestreams.go @@ -21,6 +21,7 @@ type ImageStreamInterface interface { Update(stream *imageapi.ImageStream) (*imageapi.ImageStream, error) Delete(name string) error Watch(label labels.Selector, field fields.Selector, resourceVersion string) (watch.Interface, error) + UpdateStatus(stream *imageapi.ImageStream) (*imageapi.ImageStream, error) } // ImageStreamNamespaceGetter exposes methods to get ImageStreams by Namespace @@ -100,3 +101,10 @@ func (c *imageStreams) Watch(label labels.Selector, field fields.Selector, resou FieldsSelectorParam(field). Watch() } + +// UpdateStatus updates the image stream's status. Returns the server's representation of the image stream, and an error, if it occurs. +func (c *imageStreams) UpdateStatus(stream *imageapi.ImageStream) (result *imageapi.ImageStream, err error) { + result = &imageapi.ImageStream{} + err = c.r.Put().Namespace(c.ns).Resource("imageStreams").Name(stream.Name).SubResource("status").Body(stream).Do().Into(result) + return +} diff --git a/pkg/cmd/admin/prune/images.go b/pkg/cmd/admin/prune/images.go new file mode 100644 index 000000000000..76db887fc9c0 --- /dev/null +++ b/pkg/cmd/admin/prune/images.go @@ -0,0 +1,203 @@ +package prune + +import ( + "crypto/x509" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "strings" + "text/tabwriter" + "time" + + kapi "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + kclient "github.com/GoogleCloudPlatform/kubernetes/pkg/client" + "github.com/GoogleCloudPlatform/kubernetes/pkg/fields" + cmdutil "github.com/GoogleCloudPlatform/kubernetes/pkg/kubectl/cmd/util" + "github.com/GoogleCloudPlatform/kubernetes/pkg/labels" + "github.com/GoogleCloudPlatform/kubernetes/pkg/util" + "github.com/golang/glog" + "github.com/openshift/origin/pkg/cmd/util/clientcmd" + imageapi "github.com/openshift/origin/pkg/image/api" + "github.com/openshift/origin/pkg/image/prune" + "github.com/spf13/cobra" +) + +const imagesLongDesc = ` +` + +const PruneImagesRecommendedName = "images" + +type pruneImagesConfig struct { + DryRun bool + KeepYoungerThan time.Duration + TagRevisionsToKeep int + CABundle string +} + +func NewCmdPruneImages(f *clientcmd.Factory, parentName, name string, out io.Writer) *cobra.Command { + cfg := &pruneImagesConfig{ + DryRun: true, + KeepYoungerThan: 60 * time.Minute, + TagRevisionsToKeep: 3, + } + + cmd := &cobra.Command{ + Use: name, + Short: "Prune images", + Long: fmt.Sprintf(imagesLongDesc, parentName, name), + + Run: func(cmd *cobra.Command, args []string) { + if len(args) > 0 { + glog.Fatalf("No arguments are allowed to this command") + } + + osClient, kClient, err := f.Clients() + cmdutil.CheckErr(err) + + allImages, err := osClient.Images().List(labels.Everything(), fields.Everything()) + cmdutil.CheckErr(err) + + allStreams, err := osClient.ImageStreams(kapi.NamespaceAll).List(labels.Everything(), fields.Everything()) + cmdutil.CheckErr(err) + + allPods, err := kClient.Pods(kapi.NamespaceAll).List(labels.Everything(), fields.Everything()) + cmdutil.CheckErr(err) + + allRCs, err := kClient.ReplicationControllers(kapi.NamespaceAll).List(labels.Everything()) + cmdutil.CheckErr(err) + + allBCs, err := osClient.BuildConfigs(kapi.NamespaceAll).List(labels.Everything(), fields.Everything()) + cmdutil.CheckErr(err) + + allBuilds, err := osClient.Builds(kapi.NamespaceAll).List(labels.Everything(), fields.Everything()) + cmdutil.CheckErr(err) + + allDCs, err := osClient.DeploymentConfigs(kapi.NamespaceAll).List(labels.Everything(), fields.Everything()) + cmdutil.CheckErr(err) + + pruner := prune.NewImagePruner( + cfg.KeepYoungerThan, + cfg.TagRevisionsToKeep, + allImages, + allStreams, + allPods, + allRCs, + allBCs, + allBuilds, + allDCs, + ) + + w := tabwriter.NewWriter(out, 10, 4, 3, ' ', 0) + defer w.Flush() + + var streams util.StringSet + printImageHeader := true + describingImagePruneFunc := func(image *imageapi.Image) error { + if printImageHeader { + printImageHeader = false + fmt.Fprintf(w, "IMAGE\tSTREAMS") + } + + if streams.Len() > 0 { + fmt.Fprintf(w, strings.Join(streams.List(), ", ")) + } + + fmt.Fprintf(w, "\n%s\t", image.Name) + streams = util.NewStringSet() + + return nil + } + + describingImageStreamPruneFunc := func(stream *imageapi.ImageStream, image *imageapi.Image) (*imageapi.ImageStream, error) { + streams.Insert(stream.Status.DockerImageRepository) + return stream, nil + } + + printLayerHeader := true + describingLayerPruneFunc := func(registryURL, repo, layer string) error { + if printLayerHeader { + printLayerHeader = false + // need to print the remaining streams for the last image + if streams.Len() > 0 { + fmt.Fprintf(w, strings.Join(streams.List(), ", ")) + } + fmt.Fprintf(w, "\n\nREGISTRY\tSTREAM\tLAYER\n") + } + fmt.Fprintf(w, "%s\t%s\t%s\n", registryURL, repo, layer) + return nil + } + + var ( + imagePruneFunc prune.ImagePruneFunc + imageStreamPruneFunc prune.ImageStreamPruneFunc + layerPruneFunc prune.LayerPruneFunc + blobPruneFunc prune.BlobPruneFunc + manifestPruneFunc prune.ManifestPruneFunc + ) + + // get the client config so we can get the TLS config + clientConfig, err := f.OpenShiftClientConfig.ClientConfig() + cmdutil.CheckErr(err) + + tlsConfig, err := kclient.TLSConfigFor(clientConfig) + cmdutil.CheckErr(err) + + // if the user specified a CA on the command line, add it to the + // client config's CA roots + if len(cfg.CABundle) > 0 { + data, err := ioutil.ReadFile(cfg.CABundle) + cmdutil.CheckErr(err) + if tlsConfig.RootCAs == nil { + tlsConfig.RootCAs = x509.NewCertPool() + } + tlsConfig.RootCAs.AppendCertsFromPEM(data) + } + + registryClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + } + + switch cfg.DryRun { + case false: + imagePruneFunc = func(image *imageapi.Image) error { + describingImagePruneFunc(image) + return prune.DeletingImagePruneFunc(osClient.Images())(image) + } + imageStreamPruneFunc = func(stream *imageapi.ImageStream, image *imageapi.Image) (*imageapi.ImageStream, error) { + describingImageStreamPruneFunc(stream, image) + return prune.DeletingImageStreamPruneFunc(osClient)(stream, image) + } + layerPruneFunc = func(registryURL, repo, layer string) error { + describingLayerPruneFunc(registryURL, repo, layer) + return prune.DeletingLayerPruneFunc(registryClient)(registryURL, repo, layer) + } + blobPruneFunc = prune.DeletingBlobPruneFunc(registryClient) + manifestPruneFunc = prune.DeletingManifestPruneFunc(registryClient) + default: + fmt.Fprintln(os.Stderr, "Dry run enabled - no modifications will be made.") + imagePruneFunc = describingImagePruneFunc + imageStreamPruneFunc = describingImageStreamPruneFunc + layerPruneFunc = describingLayerPruneFunc + blobPruneFunc = func(registryURL, blob string) error { + return nil + } + manifestPruneFunc = func(registryURL, repo, manifest string) error { + return nil + } + } + + pruner.Run(imagePruneFunc, imageStreamPruneFunc, layerPruneFunc, blobPruneFunc, manifestPruneFunc) + }, + } + + cmd.Flags().BoolVar(&cfg.DryRun, "dry-run", cfg.DryRun, "Perform a build pruning dry-run, displaying what would be deleted but not actually deleting anything.") + cmd.Flags().DurationVar(&cfg.KeepYoungerThan, "keep-younger-than", cfg.KeepYoungerThan, "Specify the minimum age of a build for it to be considered a candidate for pruning.") + cmd.Flags().IntVar(&cfg.TagRevisionsToKeep, "keep-tag-revisions", cfg.TagRevisionsToKeep, "Specify the number of image revisions for a tag in an image stream that will be preserved.") + cmd.Flags().StringVar(&cfg.CABundle, "certificate-authority", cfg.CABundle, "The path to a certificate authority bundle to use when communicating with the OpenShift-managed registries. Defaults to the certificate authority data from the current user's config file.") + + return cmd +} diff --git a/pkg/cmd/admin/prune/prune.go b/pkg/cmd/admin/prune/prune.go index 12b40f9e10c8..4c96e9103fdb 100644 --- a/pkg/cmd/admin/prune/prune.go +++ b/pkg/cmd/admin/prune/prune.go @@ -26,6 +26,7 @@ func NewCommandPrune(name, fullName string, f *clientcmd.Factory, out io.Writer) cmds.AddCommand(NewCmdPruneBuilds(f, fullName, PruneBuildsRecommendedName, out)) cmds.AddCommand(NewCmdPruneDeployments(f, fullName, PruneDeploymentsRecommendedName, out)) + cmds.AddCommand(NewCmdPruneImages(f, fullName, PruneImagesRecommendedName, out)) return cmds } diff --git a/pkg/cmd/dockerregistry/dockerregistry.go b/pkg/cmd/dockerregistry/dockerregistry.go index 08128a0ce0d6..a9aa816b84c2 100644 --- a/pkg/cmd/dockerregistry/dockerregistry.go +++ b/pkg/cmd/dockerregistry/dockerregistry.go @@ -12,31 +12,17 @@ import ( log "github.com/Sirupsen/logrus" "github.com/docker/distribution/configuration" "github.com/docker/distribution/context" - "github.com/docker/distribution/health" + "github.com/docker/distribution/digest" + "github.com/docker/distribution/registry/api/v2" + "github.com/docker/distribution/registry/auth" "github.com/docker/distribution/registry/handlers" _ "github.com/docker/distribution/registry/storage/driver/filesystem" _ "github.com/docker/distribution/registry/storage/driver/s3" "github.com/docker/distribution/version" gorillahandlers "github.com/gorilla/handlers" - _ "github.com/openshift/origin/pkg/dockerregistry/server" + "github.com/openshift/origin/pkg/dockerregistry/server" ) -type healthHandler struct { - delegate http.Handler -} - -func newHealthHandler(delegate http.Handler) http.Handler { - return &healthHandler{delegate} -} - -func (h *healthHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - if req.URL.Path == "/healthz" { - health.StatusHandler(w, req) - return - } - h.delegate.ServeHTTP(w, req) -} - // Execute runs the Docker registry. func Execute(configFile io.Reader) { config, err := configuration.Parse(configFile) @@ -55,8 +41,58 @@ func Execute(configFile io.Reader) { ctx := context.Background() app := handlers.NewApp(ctx, *config) - handler := newHealthHandler(app) - handler = gorillahandlers.CombinedLoggingHandler(os.Stdout, handler) + + // register OpenShift routes + app.RegisterRoute(app.NewRoute().Path("/healthz"), server.HealthzHandler, handlers.NameNotRequired, handlers.NoCustomAccessRecords) + + // TODO add https scheme + adminRouter := app.NewRoute().PathPrefix("/admin/").Subrouter() + + pruneAccessRecords := func(*http.Request) []auth.Access { + return []auth.Access{ + { + Resource: auth.Resource{ + Type: "admin", + }, + Action: "prune", + }, + } + } + + app.RegisterRoute( + // DELETE /admin/blobs/ + adminRouter.Path("/blobs/{digest:"+digest.DigestRegexp.String()+"}").Methods("DELETE"), + // handler + server.BlobDispatcher, + // repo name not required in url + handlers.NameNotRequired, + // custom access records + pruneAccessRecords, + ) + + app.RegisterRoute( + // DELETE /admin//manifests/ + adminRouter.Path("/{name:"+v2.RepositoryNameRegexp.String()+"}/manifests/{digest:"+digest.DigestRegexp.String()+"}").Methods("DELETE"), + // handler + server.ManifestDispatcher, + // repo name required in url + handlers.NameRequired, + // custom access records + pruneAccessRecords, + ) + + app.RegisterRoute( + // DELETE /admin//layers/ + adminRouter.Path("/{name:"+v2.RepositoryNameRegexp.String()+"}/layers/{digest:"+digest.DigestRegexp.String()+"}").Methods("DELETE"), + // handler + server.LayerDispatcher, + // repo name required in url + handlers.NameRequired, + // custom access records + pruneAccessRecords, + ) + + handler := gorillahandlers.CombinedLoggingHandler(os.Stdout, app) if config.HTTP.TLS.Certificate == "" { context.GetLogger(app).Infof("listening on %v", config.HTTP.Addr) diff --git a/pkg/cmd/experimental/buildchain/buildchain.go b/pkg/cmd/experimental/buildchain/buildchain.go index bb64ec27b265..f09f1c9b4c93 100644 --- a/pkg/cmd/experimental/buildchain/buildchain.go +++ b/pkg/cmd/experimental/buildchain/buildchain.go @@ -256,7 +256,7 @@ func getStreams(configs []buildapi.BuildConfig) map[string][]string { glog.V(1).Infof("Scanning buildconfig %v", cfg) for _, tr := range cfg.Triggers { glog.V(1).Infof("Scanning trigger %v", tr) - from := buildutil.GetImageStreamForStrategy(&cfg) + from := buildutil.GetImageStreamForStrategy(cfg.Parameters.Strategy) glog.V(1).Infof("Strategy from= %v", from) if tr.ImageChange != nil && from != nil && from.Name != "" { glog.V(1).Infof("found ICT with from %s kind %s", from.Name, from.Kind) @@ -316,7 +316,7 @@ func findStreamDeps(stream, tag string, buildConfigList []buildapi.BuildConfig) var childNamespace, childName, childTag string for _, cfg := range buildConfigList { for _, tr := range cfg.Triggers { - from := buildutil.GetImageStreamForStrategy(&cfg) + from := buildutil.GetImageStreamForStrategy(cfg.Parameters.Strategy) if from == nil || from.Kind != "ImageStreamTag" || tr.ImageChange == nil { continue } diff --git a/pkg/dockerregistry/server/admin.go b/pkg/dockerregistry/server/admin.go new file mode 100644 index 000000000000..97cbb77f8af1 --- /dev/null +++ b/pkg/dockerregistry/server/admin.go @@ -0,0 +1,142 @@ +package server + +import ( + "fmt" + "net/http" + + ctxu "github.com/docker/distribution/context" + "github.com/docker/distribution/digest" + "github.com/docker/distribution/registry/api/v2" + "github.com/docker/distribution/registry/handlers" + gorillahandlers "github.com/gorilla/handlers" +) + +// BlobDispatcher takes the request context and builds the appropriate handler +// for handling blob requests. +func BlobDispatcher(ctx *handlers.Context, r *http.Request) http.Handler { + reference := ctxu.GetStringValue(ctx, "vars.digest") + dgst, _ := digest.ParseDigest(reference) + + blobHandler := &blobHandler{ + Context: ctx, + Digest: dgst, + } + + return gorillahandlers.MethodHandler{ + "DELETE": http.HandlerFunc(blobHandler.Delete), + } +} + +// blobHandler handles http operations on blobs. +type blobHandler struct { + *handlers.Context + + Digest digest.Digest +} + +// Delete deletes the blob from the storage backend. +func (bh *blobHandler) Delete(w http.ResponseWriter, req *http.Request) { + defer req.Body.Close() + + if len(bh.Digest) == 0 { + bh.Errors.Push(v2.ErrorCodeBlobUnknown) + w.WriteHeader(http.StatusNotFound) + return + } + + err := bh.Registry().Blobs().Delete(bh.Digest) + if err != nil { + bh.Errors.PushErr(fmt.Errorf("error deleting blob %q: %v", bh.Digest, err)) + w.WriteHeader(http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// LayerDispatcher takes the request context and builds the appropriate handler +// for handling layer requests. +func LayerDispatcher(ctx *handlers.Context, r *http.Request) http.Handler { + reference := ctxu.GetStringValue(ctx, "vars.digest") + dgst, _ := digest.ParseDigest(reference) + + layerHandler := &layerHandler{ + Context: ctx, + Digest: dgst, + } + + return gorillahandlers.MethodHandler{ + "DELETE": http.HandlerFunc(layerHandler.Delete), + } +} + +// layerHandler handles http operations on layers. +type layerHandler struct { + *handlers.Context + + Digest digest.Digest +} + +// Delete deletes the layer link from the repository from the storage backend. +func (lh *layerHandler) Delete(w http.ResponseWriter, req *http.Request) { + defer req.Body.Close() + + if len(lh.Digest) == 0 { + lh.Errors.Push(v2.ErrorCodeBlobUnknown) + w.WriteHeader(http.StatusNotFound) + return + } + + err := lh.Repository.Layers().Delete(lh.Digest) + if err != nil { + lh.Errors.PushErr(fmt.Errorf("error unlinking layer %q from repo %q: %v", lh.Digest, lh.Repository.Name(), err)) + w.WriteHeader(http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// ManifestDispatcher takes the request context and builds the appropriate +// handler for handling manifest requests. +func ManifestDispatcher(ctx *handlers.Context, r *http.Request) http.Handler { + reference := ctxu.GetStringValue(ctx, "vars.digest") + dgst, _ := digest.ParseDigest(reference) + + manifestHandler := &manifestHandler{ + Context: ctx, + Digest: dgst, + } + + return gorillahandlers.MethodHandler{ + "DELETE": http.HandlerFunc(manifestHandler.Delete), + } +} + +// manifestHandler handles http operations on mainfests. +type manifestHandler struct { + *handlers.Context + + Digest digest.Digest +} + +// Delete deletes the manifest information from the repository from the storage +// backend. +func (mh *manifestHandler) Delete(w http.ResponseWriter, req *http.Request) { + defer req.Body.Close() + + if len(mh.Digest) == 0 { + mh.Errors.Push(v2.ErrorCodeManifestUnknown) + w.WriteHeader(http.StatusNotFound) + return + } + + err := mh.Repository.Manifests().Delete(mh.Context, mh.Digest) + if err != nil { + mh.Errors.PushErr(fmt.Errorf("error deleting repo %q, manifest %q: %v", mh.Repository.Name(), mh.Digest, err)) + w.WriteHeader(http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/pkg/dockerregistry/server/auth.go b/pkg/dockerregistry/server/auth.go index 1c9a73981c58..813abae9fbee 100644 --- a/pkg/dockerregistry/server/auth.go +++ b/pkg/dockerregistry/server/auth.go @@ -132,7 +132,7 @@ func (ac *AccessController) Authorized(ctx context.Context, accessRecords ...reg // In case of docker login, hits endpoint /v2 if len(accessRecords) == 0 { - err = VerifyOpenShiftUser(user, client) + err = verifyOpenShiftUser(user, client) if err != nil { challenge.err = err return nil, challenge @@ -143,37 +143,51 @@ func (ac *AccessController) Authorized(ctx context.Context, accessRecords ...reg for _, access := range accessRecords { log.Debugf("%s:%s:%s", access.Resource.Type, access.Resource.Name, access.Action) - if access.Resource.Type != "repository" { - continue - } + switch access.Resource.Type { + case "repository": + repoParts := strings.SplitN(access.Resource.Name, "/", 2) + if len(repoParts) != 2 { + challenge.err = ErrNamespaceRequired + return nil, challenge + } - repoParts := strings.SplitN(access.Resource.Name, "/", 2) - if len(repoParts) != 2 { - challenge.err = ErrNamespaceRequired - return nil, challenge - } + verb := "" + switch access.Action { + case "push": + verb = "update" + case "pull": + verb = "get" + default: + challenge.err = fmt.Errorf("Unknown action: %s", access.Action) + return nil, challenge + } - verb := "" - switch access.Action { - case "push": - verb = "update" - case "pull": - verb = "get" - default: - challenge.err = fmt.Errorf("Unknown action: %s", access.Action) - return nil, challenge - } + if err := verifyImageStreamAccess(repoParts[0], repoParts[1], verb, client); err != nil { + challenge.err = err + return nil, challenge + } - err = VerifyOpenShiftAccess(repoParts[0], repoParts[1], verb, client) - if err != nil { - challenge.err = err - return nil, challenge + return WithUserClient(ctx, client), nil + case "admin": + switch access.Action { + case "prune": + if err := verifyPruneAccess(client); err != nil { + challenge.err = err + return nil, challenge + } + + return WithUserClient(ctx, client), nil + default: + challenge.err = fmt.Errorf("Unknown action: %s", access.Action) + return nil, challenge + } } } - return WithUserClient(ctx, client), nil + + return ctx, nil } -func VerifyOpenShiftUser(user string, client *client.Client) error { +func verifyOpenShiftUser(user string, client *client.Client) error { userObj, err := client.Users().Get("~") if err != nil { log.Errorf("Get user failed with error: %s", err) @@ -186,7 +200,7 @@ func VerifyOpenShiftUser(user string, client *client.Client) error { return nil } -func VerifyOpenShiftAccess(namespace, imageRepo, verb string, client *client.Client) error { +func verifyImageStreamAccess(namespace, imageRepo, verb string, client *client.Client) error { sar := authorizationapi.SubjectAccessReview{ Verb: verb, Resource: "imageStreams", @@ -203,3 +217,20 @@ func VerifyOpenShiftAccess(namespace, imageRepo, verb string, client *client.Cli } return nil } + +func verifyPruneAccess(client *client.Client) error { + sar := authorizationapi.SubjectAccessReview{ + Verb: "delete", + Resource: "images", + } + response, err := client.ClusterSubjectAccessReviews().Create(&sar) + if err != nil { + log.Errorf("OpenShift client error: %s", err) + return ErrOpenShiftAccessDenied + } + if !response.Allowed { + log.Errorf("OpenShift access denied: %s", response.Reason) + return ErrOpenShiftAccessDenied + } + return nil +} diff --git a/pkg/dockerregistry/server/auth_test.go b/pkg/dockerregistry/server/auth_test.go index 758fea3eedc2..bb83f162f043 100644 --- a/pkg/dockerregistry/server/auth_test.go +++ b/pkg/dockerregistry/server/auth_test.go @@ -11,9 +11,9 @@ import ( "golang.org/x/net/context" ) -// TestVerifyOpenShiftAccess mocks openshift http request/response and +// TestVerifyImageStreamAccess mocks openshift http request/response and // tests invalid/valid/scoped openshift tokens. -func TestVerifyOpenShiftAccess(t *testing.T) { +func TestVerifyImageStreamAccess(t *testing.T) { tests := []struct { openshiftStatusCode int openshiftResponse string @@ -44,13 +44,13 @@ func TestVerifyOpenShiftAccess(t *testing.T) { if err != nil { t.Fatal(err) } - err = VerifyOpenShiftAccess("foo", "bar", "create", client) + err = verifyImageStreamAccess("foo", "bar", "create", client) if err == nil || test.expectedError == nil { if err != test.expectedError { - t.Fatal("VerifyOpenShiftAccess did not get expected error - got %s - expected %s", err, test.expectedError) + t.Fatal("verifyImageStreamAccess did not get expected error - got %s - expected %s", err, test.expectedError) } } else if err.Error() != test.expectedError.Error() { - t.Fatal("VerifyOpenShiftAccess did not get expected error - got %s - expected %s", err, test.expectedError) + t.Fatal("verifyImageStreamAccess did not get expected error - got %s - expected %s", err, test.expectedError) } server.Close() } diff --git a/pkg/dockerregistry/server/healthz.go b/pkg/dockerregistry/server/healthz.go new file mode 100644 index 000000000000..cb8eaac7032b --- /dev/null +++ b/pkg/dockerregistry/server/healthz.go @@ -0,0 +1,12 @@ +package server + +import ( + "net/http" + + "github.com/docker/distribution/health" + "github.com/docker/distribution/registry/handlers" +) + +func HealthzHandler(ctx *handlers.Context, r *http.Request) http.Handler { + return http.HandlerFunc(health.StatusHandler) +} diff --git a/pkg/dockerregistry/server/repositorymiddleware.go b/pkg/dockerregistry/server/repositorymiddleware.go index 34b4f4a0357f..524c76f5d541 100644 --- a/pkg/dockerregistry/server/repositorymiddleware.go +++ b/pkg/dockerregistry/server/repositorymiddleware.go @@ -161,6 +161,9 @@ func (r *repository) Put(ctx context.Context, manifest *manifest.SignedManifest) Image: imageapi.Image{ ObjectMeta: kapi.ObjectMeta{ Name: dgst.String(), + Annotations: map[string]string{ + imageapi.ManagedByOpenShiftAnnotation: "true", + }, }, DockerImageReference: fmt.Sprintf("%s/%s/%s@%s", r.registryAddr, r.namespace, r.name, dgst.String()), DockerImageManifest: string(payload), @@ -221,9 +224,11 @@ func (r *repository) Put(ctx context.Context, manifest *manifest.SignedManifest) return nil } -// Delete deletes the manifest with digest `dgst`. +// Delete deletes the manifest with digest `dgst`. Note: Image resources +// in OpenShift are deleted via 'osadm prune images'. This function deletes +// the content related to the manifest in the registry's storage (signatures). func (r *repository) Delete(ctx context.Context, dgst digest.Digest) error { - return r.registryClient.Images().Delete(dgst.String()) + return r.Repository.Manifests().Delete(ctx, dgst) } // getImageStream retrieves the ImageStream for r. diff --git a/pkg/image/api/types.go b/pkg/image/api/types.go index 816d57c0e3cb..48818acb6f0b 100644 --- a/pkg/image/api/types.go +++ b/pkg/image/api/types.go @@ -13,6 +13,8 @@ type ImageList struct { Items []Image `json:"items"` } +const ManagedByOpenShiftAnnotation = "openshift.io/image.managed" + // Image is an immutable representation of a Docker image and metadata at a point in time. type Image struct { kapi.TypeMeta `json:",inline"` diff --git a/pkg/image/prune/imagepruner.go b/pkg/image/prune/imagepruner.go new file mode 100644 index 000000000000..19d4e08dbe5e --- /dev/null +++ b/pkg/image/prune/imagepruner.go @@ -0,0 +1,671 @@ +package prune + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + kapi "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/util" + "github.com/golang/glog" + gonum "github.com/gonum/graph" + "github.com/openshift/origin/pkg/api/graph" + buildapi "github.com/openshift/origin/pkg/build/api" + buildutil "github.com/openshift/origin/pkg/build/util" + "github.com/openshift/origin/pkg/client" + deployapi "github.com/openshift/origin/pkg/deploy/api" + imageapi "github.com/openshift/origin/pkg/image/api" + "github.com/openshift/origin/pkg/image/registry/imagestreamimage" +) + +// pruneAlgorithm contains the various settings to use when evaluating images +// and layers for pruning. +type pruneAlgorithm struct { + keepYoungerThan time.Duration + tagRevisionsToKeep int +} + +// ImagePruneFunc is a function that is invoked for each image that is +// prunable. +type ImagePruneFunc func(image *imageapi.Image) error +type ImageStreamPruneFunc func(stream *imageapi.ImageStream, image *imageapi.Image) (*imageapi.ImageStream, error) +type LayerPruneFunc func(registryURL, repo, layer string) error +type BlobPruneFunc func(registryURL, blob string) error +type ManifestPruneFunc func(registryURL, repo, manifest string) error + +// ImagePruner knows how to prune images and layers. +type ImagePruner interface { + // Run prunes images and layers. + Run(pruneImage ImagePruneFunc, pruneStream ImageStreamPruneFunc, pruneLayer LayerPruneFunc, pruneBlob BlobPruneFunc, pruneManifest ManifestPruneFunc) +} + +// imagePruner implements ImagePruner. +type imagePruner struct { + g graph.Graph + algorithm pruneAlgorithm +} + +var _ ImagePruner = &imagePruner{} + +/* +NewImagePruner creates a new ImagePruner. + +Images younger than keepYoungerThan and images referenced by image streams +and/or pods younger than keepYoungerThan are preserved. All other images are +candidates for pruning. For example, if keepYoungerThan is 60m, and an +ImageStream is only 59 minutes old, none of the images it references are +eligible for pruning. + +tagRevisionsToKeep is the number of revisions per tag in an image stream's +status.tags that are preserved and ineligible for pruning. Any revision older +than tagRevisionsToKeep is eligible for pruning. + +images, streams, pods, rcs, bcs, builds, and dcs are the resources used to run +the pruning algorithm. These should be the full list for each type from the +cluster; otherwise, the pruning algorithm might result in incorrect +calculations and premature pruning. + +The ImagePruner performs the following logic: remove any image contaning the +annotation openshift.io/image.managed=true that was created at least *n* +minutes ago and is *not* currently referenced by: + +- any pod created less than *n* minutes ago +- any image stream created less than *n* minutes ago +- any running pods +- any pending pods +- any replication controllers +- any deployment configs +- any build configs +- any builds +- the n most recent tag revisions in an image stream's status.tags + +When removing an image, remove all references to the image from all +ImageStreams having a reference to the image in `status.tags`. + +Also automatically remove any image layer that is no longer referenced by any +images. +*/ +func NewImagePruner(keepYoungerThan time.Duration, tagRevisionsToKeep int, images *imageapi.ImageList, streams *imageapi.ImageStreamList, pods *kapi.PodList, rcs *kapi.ReplicationControllerList, bcs *buildapi.BuildConfigList, builds *buildapi.BuildList, dcs *deployapi.DeploymentConfigList) ImagePruner { + g := graph.New() + + glog.V(1).Infof("Creating image pruner with keepYoungerThan=%v, tagRevisionsToKeep=%d", keepYoungerThan, tagRevisionsToKeep) + + algorithm := pruneAlgorithm{ + keepYoungerThan: keepYoungerThan, + tagRevisionsToKeep: tagRevisionsToKeep, + } + + addImagesToGraph(g, images, algorithm) + addImageStreamsToGraph(g, streams, algorithm) + addPodsToGraph(g, pods, algorithm) + addReplicationControllersToGraph(g, rcs) + addBuildConfigsToGraph(g, bcs) + addBuildsToGraph(g, builds) + addDeploymentConfigsToGraph(g, dcs) + + return &imagePruner{ + g: g, + algorithm: algorithm, + } +} + +// addImagesToGraph adds all images to the graph that belong to one of the +// registries in the algorithm and are at least as old as the minimum age +// threshold as specified by the algorithm. It also adds all the images' layers +// to the graph. +func addImagesToGraph(g graph.Graph, images *imageapi.ImageList, algorithm pruneAlgorithm) { + for i := range images.Items { + image := &images.Items[i] + + glog.V(4).Infof("Examining image %q", image.Name) + + if image.Annotations == nil { + glog.V(4).Infof("Image %q with DockerImageReference %q belongs to an external registry - skipping", image.Name, image.DockerImageReference) + continue + } + if value, ok := image.Annotations[imageapi.ManagedByOpenShiftAnnotation]; !ok || value != "true" { + glog.V(4).Infof("Image %q with DockerImageReference %q belongs to an external registry - skipping", image.Name, image.DockerImageReference) + continue + } + + age := util.Now().Sub(image.CreationTimestamp.Time) + if age < algorithm.keepYoungerThan { + glog.V(4).Infof("Image %q is younger than minimum pruning age, skipping (age=%v)", image.Name, age) + continue + } + + glog.V(4).Infof("Adding image %q to graph", image.Name) + imageNode := graph.Image(g, image) + + manifest := imageapi.DockerImageManifest{} + if err := json.Unmarshal([]byte(image.DockerImageManifest), &manifest); err != nil { + glog.Errorf("Unable to extract manifest from image: %v. This image's layers won't be pruned if the image is pruned now.", err) + continue + } + + for _, layer := range manifest.FSLayers { + glog.V(4).Infof("Adding image layer %q to graph", layer.DockerBlobSum) + layerNode := graph.ImageLayer(g, layer.DockerBlobSum) + g.AddEdge(imageNode, layerNode, graph.ReferencedImageLayerGraphEdgeKind) + } + } +} + +// addImageStreamsToGraph adds all the streams to the graph. The most recent n +// image revisions for a tag will be preserved, where n is specified by the +// algorithm's tagRevisionsToKeep. Image revisions older than n are candidates +// for pruning. if the image stream's age is at least as old as the minimum +// threshold in algorithm. Otherwise, if the image stream is younger than the +// threshold, all image revisions for that stream are ineligible for pruning. +// +// addImageStreamsToGraph also adds references from each stream to all the +// layers it references (via each image a stream references). +func addImageStreamsToGraph(g graph.Graph, streams *imageapi.ImageStreamList, algorithm pruneAlgorithm) { + for i := range streams.Items { + stream := &streams.Items[i] + + glog.V(4).Infof("Examining image stream %s/%s", stream.Namespace, stream.Name) + + // use a weak reference for old image revisions by default + oldImageRevisionReferenceKind := graph.WeakReferencedImageGraphEdgeKind + + age := util.Now().Sub(stream.CreationTimestamp.Time) + if age < algorithm.keepYoungerThan { + // stream's age is below threshold - use a strong reference for old image revisions instead + glog.V(4).Infof("Stream %s/%s is below age threshold - none of its images are eligible for pruning", stream.Namespace, stream.Name) + oldImageRevisionReferenceKind = graph.ReferencedImageGraphEdgeKind + } + + glog.V(4).Infof("Adding image stream %s/%s to graph", stream.Namespace, stream.Name) + isNode := graph.ImageStream(g, stream) + imageStreamNode := isNode.(*graph.ImageStreamNode) + + for tag, history := range stream.Status.Tags { + for i := range history.Items { + n := graph.FindImage(g, history.Items[i].Image) + if n == nil { + glog.V(1).Infof("Unable to find image %q in graph (from tag=%q, revision=%d, dockerImageReference=%s)", history.Items[i].Image, tag, i, history.Items[i].DockerImageReference) + continue + } + imageNode := n.(*graph.ImageNode) + + var kind int + switch { + case i < algorithm.tagRevisionsToKeep: + kind = graph.ReferencedImageGraphEdgeKind + default: + kind = oldImageRevisionReferenceKind + } + + glog.V(4).Infof("Checking for existing strong reference from stream %s/%s to image %s", stream.Namespace, stream.Name, imageNode.Image.Name) + if edge := g.EdgeBetween(imageStreamNode, imageNode); edge != nil && g.EdgeKind(edge) == graph.ReferencedImageGraphEdgeKind { + glog.V(4).Infof("Strong reference found") + continue + } + + glog.V(4).Infof("Adding edge (kind=%d) from %q to %q", kind, imageStreamNode.UniqueName.UniqueName(), imageNode.UniqueName.UniqueName()) + g.AddEdge(imageStreamNode, imageNode, kind) + + glog.V(4).Infof("Adding stream->layer references") + // add stream -> layer references so we can prune them later + for _, s := range g.Successors(imageNode) { + if g.Kind(s) != graph.ImageLayerGraphKind { + continue + } + glog.V(4).Infof("Adding reference from stream %q to layer %q", stream.Name, s.(*graph.ImageLayerNode).Layer) + g.AddEdge(imageStreamNode, s, graph.ReferencedImageLayerGraphEdgeKind) + } + } + } + } +} + +// addPodsToGraph adds pods to the graph. +// +// A pod is only *excluded* from being added to the graph if its phase is not +// pending or running and it is at least as old as the minimum age threshold +// defined by algorithm. +// +// Edges are added to the graph from each pod to the images specified by that +// pod's list of containers, as long as the image is managed by OpenShift. +func addPodsToGraph(g graph.Graph, pods *kapi.PodList, algorithm pruneAlgorithm) { + for i := range pods.Items { + pod := &pods.Items[i] + + glog.V(4).Infof("Examining pod %s/%s", pod.Namespace, pod.Name) + + if pod.Status.Phase != kapi.PodRunning && pod.Status.Phase != kapi.PodPending { + age := util.Now().Sub(pod.CreationTimestamp.Time) + if age >= algorithm.keepYoungerThan { + glog.V(4).Infof("Pod %s/%s is not running or pending and age is at least minimum pruning age - skipping", pod.Namespace, pod.Name) + // not pending or running, age is at least minimum pruning age, skip + continue + } + } + + glog.V(4).Infof("Adding pod %s/%s to graph", pod.Namespace, pod.Name) + podNode := graph.Pod(g, pod) + + addPodSpecToGraph(g, &pod.Spec, podNode) + } +} + +// Edges are added to the graph from each predecessor (pod or replication +// controller) to the images specified by the pod spec's list of containers, as +// long as the image is managed by OpenShift. +func addPodSpecToGraph(g graph.Graph, spec *kapi.PodSpec, predecessor gonum.Node) { + for j := range spec.Containers { + container := spec.Containers[j] + + glog.V(4).Infof("Examining container image %q", container.Image) + + ref, err := imageapi.ParseDockerImageReference(container.Image) + if err != nil { + glog.Errorf("Unable to parse docker image reference %q: %v", container.Image, err) + continue + } + + if len(ref.ID) == 0 { + glog.V(4).Infof("%q has no image ID", container.Image) + continue + } + + imageNode := graph.FindImage(g, ref.ID) + if imageNode == nil { + glog.Infof("Unable to find image %q in the graph", ref.ID) + continue + } + + glog.V(4).Infof("Adding edge from pod to image") + g.AddEdge(predecessor, imageNode, graph.ReferencedImageGraphEdgeKind) + } +} + +// addReplicationControllersToGraph adds replication controllers to the graph. +// +// Edges are added to the graph from each replication controller to the images +// specified by its pod spec's list of containers, as long as the image is +// managed by OpenShift. +func addReplicationControllersToGraph(g graph.Graph, rcs *kapi.ReplicationControllerList) { + for i := range rcs.Items { + rc := &rcs.Items[i] + glog.V(4).Infof("Examining replication controller %s/%s", rc.Namespace, rc.Name) + rcNode := graph.ReplicationController(g, rc) + addPodSpecToGraph(g, &rc.Spec.Template.Spec, rcNode) + } +} + +// addDeploymentConfigsToGraph adds deployment configs to the graph. +// +// Edges are added to the graph from each deployment config to the images +// specified by its pod spec's list of containers, as long as the image is +// managed by OpenShift. +func addDeploymentConfigsToGraph(g graph.Graph, dcs *deployapi.DeploymentConfigList) { + for i := range dcs.Items { + dc := &dcs.Items[i] + glog.V(4).Infof("Examining deployment config %s/%s", dc.Namespace, dc.Name) + dcNode := graph.DeploymentConfig(g, dc) + addPodSpecToGraph(g, &dc.Template.ControllerTemplate.Template.Spec, dcNode) + } +} + +// addBuildConfigsToGraph adds build configs to the graph. +// +// Edges are added to the graph from each build config to the image specified by its strategy.from. +func addBuildConfigsToGraph(g graph.Graph, bcs *buildapi.BuildConfigList) { + for i := range bcs.Items { + bc := &bcs.Items[i] + glog.V(4).Infof("Examining build config %s/%s", bc.Namespace, bc.Name) + bcNode := graph.BuildConfig(g, bc) + addBuildStrategyImageReferencesToGraph(g, bc.Parameters.Strategy, bcNode) + } +} + +// addBuildsToGraph adds builds to the graph. +// +// Edges are added to the graph from each build to the image specified by its strategy.from. +func addBuildsToGraph(g graph.Graph, builds *buildapi.BuildList) { + for i := range builds.Items { + build := &builds.Items[i] + glog.V(4).Infof("Examining build %s/%s", build.Namespace, build.Name) + buildNode := graph.Build(g, build) + addBuildStrategyImageReferencesToGraph(g, build.Parameters.Strategy, buildNode) + } +} + +// Edges are added to the graph from each predecessor (build or build config) +// to the image specified by strategy.from, as long as the image is managed by +// OpenShift. +func addBuildStrategyImageReferencesToGraph(g graph.Graph, strategy buildapi.BuildStrategy, predecessor gonum.Node) { + glog.V(4).Infof("Examining build strategy with type %q", strategy.Type) + + from := buildutil.GetImageStreamForStrategy(strategy) + if from == nil { + glog.V(4).Infof("Unable to determine 'from' reference - skipping") + return + } + + glog.V(4).Infof("Examining build strategy with from: %#v", from) + + var imageID string + + switch from.Kind { + case "ImageStreamImage": + _, id, err := imagestreamimage.ParseNameAndID(from.Name) + if err != nil { + glog.V(4).Infof("Error parsing ImageStreamImage name %q: %v - skipping", from.Name, err) + return + } + imageID = id + case "DockerImage": + ref, err := imageapi.ParseDockerImageReference(from.Name) + if err != nil { + glog.V(4).Infof("Error parsing DockerImage name %q: %v - skipping", from.Name, err) + return + } + imageID = ref.ID + default: + return + } + + glog.V(4).Infof("Looking for image %q in graph", imageID) + imageNode := graph.FindImage(g, imageID) + if imageNode == nil { + glog.V(4).Infof("Unable to find image %q in graph - skipping", imageID) + return + } + + glog.V(4).Infof("Adding edge from %v to %v", predecessor, imageNode) + g.AddEdge(predecessor, imageNode, graph.ReferencedImageGraphEdgeKind) +} + +// imageNodeSubgraph returns only nodes of type ImageNode. +func imageNodeSubgraph(nodes []gonum.Node) []*graph.ImageNode { + ret := []*graph.ImageNode{} + for i := range nodes { + if node, ok := nodes[i].(*graph.ImageNode); ok { + ret = append(ret, node) + } + } + return ret +} + +// edgeKind returns true if the edge from "from" to "to" is of the desired kind. +func edgeKind(g graph.Graph, from, to gonum.Node, desiredKind int) bool { + edge := g.EdgeBetween(from, to) + kind := g.EdgeKind(edge) + return kind == desiredKind +} + +// imageIsPrunable returns true iff the image node only has weak references +// from its predecessors to it. A weak reference to an image is a reference +// from an image stream to an image where the image is not the current image +// for a tag and the image stream is at least as old as the minimum pruning +// age. +func imageIsPrunable(g graph.Graph, imageNode *graph.ImageNode) bool { + onlyWeakReferences := true + + for _, n := range g.Predecessors(imageNode) { + glog.V(4).Infof("Examining predecessor %#v", n) + if !edgeKind(g, n, imageNode, graph.WeakReferencedImageGraphEdgeKind) { + glog.V(4).Infof("Strong reference detected") + onlyWeakReferences = false + break + } + } + + return onlyWeakReferences + +} + +// pruneImages invokes imagePruneFunc with each image that is prunable, along +// with the image streams that reference the image. After imagePruneFunc is +// invoked, the image node is removed from the graph, so that layers eligible +// for pruning may later be identified. +func pruneImages(g graph.Graph, imageNodes []*graph.ImageNode, pruneImage ImagePruneFunc, pruneStream ImageStreamPruneFunc, pruneManifest ManifestPruneFunc) { + for _, imageNode := range imageNodes { + glog.V(4).Infof("Examining image %q", imageNode.Image.Name) + + if !imageIsPrunable(g, imageNode) { + glog.V(4).Infof("Image has strong references - not pruning") + continue + } + + glog.V(4).Infof("Image has only weak references - pruning") + + if err := pruneImage(imageNode.Image); err != nil { + glog.Errorf("Error pruning image %q: %v", imageNode.Image.Name, err) + } + + for _, n := range g.Predecessors(imageNode) { + if streamNode, ok := n.(*graph.ImageStreamNode); ok { + stream := streamNode.ImageStream + repoName := fmt.Sprintf("%s/%s", stream.Namespace, stream.Name) + + glog.V(4).Infof("Pruning image from stream %s", repoName) + updatedStream, err := pruneStream(stream, imageNode.Image) + if err != nil { + glog.Errorf("Error pruning image from stream: %v", err) + continue + } + + streamNode.ImageStream = updatedStream + + ref, err := imageapi.DockerImageReferenceForStream(stream) + if err != nil { + glog.Errorf("Error constructing DockerImageReference for %q: %v", repoName, err) + continue + } + + glog.V(4).Infof("Invoking pruneManifest for registry %q, repo %q, image %q", ref.Registry, repoName, imageNode.Image.Name) + if err := pruneManifest(ref.Registry, repoName, imageNode.Image.Name); err != nil { + glog.Errorf("Error pruning manifest for registry %q, repo %q, image %q: %v", ref.Registry, repoName, imageNode.Image.Name, err) + } + } + } + + // remove pruned image node from graph, for layer pruning later + g.RemoveNode(imageNode) + } +} + +// Run identifies images eligible for pruning, invoking imagePruneFunc for each +// image, and then it identifies layers eligible for pruning, invoking +// layerPruneFunc for each registry URL that has layers that can be pruned. +func (p *imagePruner) Run(pruneImage ImagePruneFunc, pruneStream ImageStreamPruneFunc, pruneLayer LayerPruneFunc, pruneBlob BlobPruneFunc, pruneManifest ManifestPruneFunc) { + allNodes := p.g.NodeList() + + imageNodes := imageNodeSubgraph(allNodes) + pruneImages(p.g, imageNodes, pruneImage, pruneStream, pruneManifest) + + layerNodes := layerNodeSubgraph(allNodes) + pruneLayers(p.g, layerNodes, pruneLayer, pruneBlob) +} + +// layerNodeSubgraph returns the subset of nodes that are ImageLayerNodes. +func layerNodeSubgraph(nodes []gonum.Node) []*graph.ImageLayerNode { + ret := []*graph.ImageLayerNode{} + for i := range nodes { + if node, ok := nodes[i].(*graph.ImageLayerNode); ok { + ret = append(ret, node) + } + } + return ret +} + +// layerIsPrunable returns true if the layer is not referenced by any images. +func layerIsPrunable(g graph.Graph, layerNode *graph.ImageLayerNode) bool { + for _, predecessor := range g.Predecessors(layerNode) { + glog.V(4).Infof("Examining layer predecessor %#v", predecessor) + if g.Kind(predecessor) == graph.ImageGraphKind { + glog.V(4).Infof("Layer has an image predecessor") + return false + } + } + + return true +} + +// streamLayerReferences returns a list of ImageStreamNodes that reference a +// given ImageLayeNode. +func streamLayerReferences(g graph.Graph, layerNode *graph.ImageLayerNode) []*graph.ImageStreamNode { + ret := []*graph.ImageStreamNode{} + + for _, predecessor := range g.Predecessors(layerNode) { + if g.Kind(predecessor) != graph.ImageStreamGraphKind { + continue + } + + ret = append(ret, predecessor.(*graph.ImageStreamNode)) + } + + return ret +} + +// pruneLayers creates a mapping of registryURLs to +// server.DeleteLayersRequest objects, invoking layerPruneFunc for each +// registryURL and request. +func pruneLayers(g graph.Graph, layerNodes []*graph.ImageLayerNode, pruneLayer LayerPruneFunc, pruneBlob BlobPruneFunc) { + for _, layerNode := range layerNodes { + glog.V(4).Infof("Examining layer %q", layerNode.Layer) + + if !layerIsPrunable(g, layerNode) { + glog.V(4).Infof("Layer %q has image references - not pruning", layerNode.Layer) + continue + } + + registries := util.NewStringSet() + + // get streams that reference layer + streamNodes := streamLayerReferences(g, layerNode) + + for _, streamNode := range streamNodes { + stream := streamNode.ImageStream + streamName := fmt.Sprintf("%s/%s", stream.Namespace, stream.Name) + glog.V(4).Infof("Layer has an image stream predecessor: %s", streamName) + + ref, err := imageapi.DockerImageReferenceForStream(stream) + if err != nil { + glog.Errorf("Error constructing DockerImageReference for %q: %v", streamName, err) + continue + } + + if !registries.Has(ref.Registry) { + registries.Insert(ref.Registry) + glog.V(4).Infof("Invoking pruneBlob with registry=%q, blob=%q", ref.Registry, layerNode.Layer) + if err := pruneBlob(ref.Registry, layerNode.Layer); err != nil { + glog.Errorf("Error invoking pruneBlob: %v", err) + } + } + + repoName := fmt.Sprintf("%s/%s", ref.Namespace, ref.Name) + glog.V(4).Infof("Invoking pruneLayer with registry=%q, repo=%q, layer=%q", ref.Registry, repoName, layerNode.Layer) + if err := pruneLayer(ref.Registry, repoName, layerNode.Layer); err != nil { + glog.Errorf("Error invoking pruneLayer: %v", err) + } + } + } +} + +// DeletingImagePruneFunc returns an ImagePruneFunc that deletes the image. +func DeletingImagePruneFunc(images client.ImageInterface) ImagePruneFunc { + return func(image *imageapi.Image) error { + glog.V(4).Infof("Deleting image %q", image.Name) + if err := images.Delete(image.Name); err != nil { + e := fmt.Errorf("Error deleting image: %v", err) + glog.Error(e) + return e + } + return nil + } +} + +func DeletingImageStreamPruneFunc(streams client.ImageStreamsNamespacer) ImageStreamPruneFunc { + return func(stream *imageapi.ImageStream, image *imageapi.Image) (*imageapi.ImageStream, error) { + glog.V(4).Infof("Checking if stream %s/%s has references to image in status.tags", stream.Namespace, stream.Name) + for tag, history := range stream.Status.Tags { + glog.V(4).Infof("Checking tag %q", tag) + newHistory := imageapi.TagEventList{} + for i, tagEvent := range history.Items { + glog.V(4).Infof("Checking tag event %d with image %q", i, tagEvent.Image) + if tagEvent.Image != image.Name { + glog.V(4).Infof("Tag event doesn't match deleting image - keeping") + newHistory.Items = append(newHistory.Items, tagEvent) + } + } + stream.Status.Tags[tag] = newHistory + } + + glog.V(4).Infof("Updating image stream %s/%s", stream.Namespace, stream.Name) + glog.V(5).Infof("Updated stream: %#v", stream) + updatedStream, err := streams.ImageStreams(stream.Namespace).UpdateStatus(stream) + if err != nil { + return nil, err + } + return updatedStream, nil + } +} + +func deleteFromRegistry(registryClient *http.Client, url string) error { + deleteFunc := func(proto, url string) error { + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + glog.Errorf("Error creating request: %v", err) + return fmt.Errorf("Error creating request: %v", err) + } + + glog.V(4).Infof("Sending request to registry") + resp, err := registryClient.Do(req) + if err != nil { + return fmt.Errorf("Error sending request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + glog.Errorf("Unexpected status code in response: %d", resp.StatusCode) + //TODO do a better job of decoding and reporting the errors? + decoder := json.NewDecoder(resp.Body) + response := make(map[string]interface{}) + decoder.Decode(&response) + return fmt.Errorf("Unexpected status code %d in response: %#v", resp.StatusCode, response) + } + + return nil + } + + var err error + for _, proto := range []string{"https", "http"} { + err = deleteFunc(proto, fmt.Sprintf("%s://%s", proto, url)) + if err == nil { + return nil + } + } + return err +} + +// DeletingLayerPruneFunc returns a LayerPruneFunc that uses registryClient to +// send a layer deletion request to the regsitry. +// +// The request URL is http://registryURL/admin//layers/ and it is +// a DELETE request. +func DeletingLayerPruneFunc(registryClient *http.Client) LayerPruneFunc { + return func(registryURL, repoName, layer string) error { + glog.V(4).Infof("Pruning registry %q, repo %q, layer %q", registryURL, repoName, layer) + return deleteFromRegistry(registryClient, fmt.Sprintf("%s/admin/%s/layers/%s", registryURL, repoName, layer)) + } +} + +func DeletingBlobPruneFunc(registryClient *http.Client) BlobPruneFunc { + return func(registryURL, blob string) error { + glog.V(4).Infof("Pruning registry %q, blob %q", registryURL, blob) + return deleteFromRegistry(registryClient, fmt.Sprintf("%s/admin/blobs/%s", registryURL, blob)) + } +} + +func DeletingManifestPruneFunc(registryClient *http.Client) ManifestPruneFunc { + return func(registryURL, repoName, manifest string) error { + glog.V(4).Infof("Pruning manifest for registry %q, repo %q, manifest %q", registryURL, repoName, manifest) + return deleteFromRegistry(registryClient, fmt.Sprintf("%s/admin/%s/manifests/%s", registryURL, repoName, manifest)) + } +} diff --git a/pkg/image/prune/imagepruner_test.go b/pkg/image/prune/imagepruner_test.go new file mode 100644 index 000000000000..269411721f12 --- /dev/null +++ b/pkg/image/prune/imagepruner_test.go @@ -0,0 +1,786 @@ +package prune + +import ( + "encoding/json" + "flag" + "fmt" + "reflect" + "testing" + "time" + + kapi "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/util" + buildapi "github.com/openshift/origin/pkg/build/api" + "github.com/openshift/origin/pkg/client" + deployapi "github.com/openshift/origin/pkg/deploy/api" + imageapi "github.com/openshift/origin/pkg/image/api" +) + +func imageList(images ...imageapi.Image) imageapi.ImageList { + return imageapi.ImageList{ + Items: images, + } +} + +func agedImage(id, ref string, ageInMinutes int64) imageapi.Image { + image := imageWithLayers(id, ref, + "tarsum.dev+sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tarsum.dev+sha256:b194de3772ebbcdc8f244f663669799ac1cb141834b7cb8b69100285d357a2b0", + "tarsum.dev+sha256:c937c4bb1c1a21cc6d94340812262c6472092028972ae69b551b1a70d4276171", + "tarsum.dev+sha256:2aaacc362ac6be2b9e9ae8c6029f6f616bb50aec63746521858e47841b90fabd", + "tarsum.dev+sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ) + + if ageInMinutes >= 0 { + image.CreationTimestamp = util.NewTime(util.Now().Add(time.Duration(-1*ageInMinutes) * time.Minute)) + } + + return image +} + +func image(id, ref string) imageapi.Image { + return agedImage(id, ref, -1) +} + +func imageWithLayers(id, ref string, layers ...string) imageapi.Image { + image := imageapi.Image{ + ObjectMeta: kapi.ObjectMeta{ + Name: id, + Annotations: map[string]string{ + imageapi.ManagedByOpenShiftAnnotation: "true", + }, + }, + DockerImageReference: ref, + } + + manifest := imageapi.DockerImageManifest{ + FSLayers: []imageapi.DockerFSLayer{}, + } + + for _, layer := range layers { + manifest.FSLayers = append(manifest.FSLayers, imageapi.DockerFSLayer{DockerBlobSum: layer}) + } + + manifestBytes, err := json.Marshal(&manifest) + if err != nil { + panic(err) + } + + image.DockerImageManifest = string(manifestBytes) + + return image +} + +func unmanagedImage(id, ref string, hasAnnotations bool, annotation, value string) imageapi.Image { + image := imageWithLayers(id, ref) + if !hasAnnotations { + image.Annotations = nil + } else { + delete(image.Annotations, imageapi.ManagedByOpenShiftAnnotation) + image.Annotations[annotation] = value + } + return image +} + +func imageWithBadManifest(id, ref string) imageapi.Image { + image := image(id, ref) + image.DockerImageManifest = "asdf" + return image +} + +func podList(pods ...kapi.Pod) kapi.PodList { + return kapi.PodList{ + Items: pods, + } +} + +func pod(namespace, name string, phase kapi.PodPhase, containerImages ...string) kapi.Pod { + return agedPod(namespace, name, phase, -1, containerImages...) +} + +func agedPod(namespace, name string, phase kapi.PodPhase, ageInMinutes int64, containerImages ...string) kapi.Pod { + pod := kapi.Pod{ + ObjectMeta: kapi.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Spec: podSpec(containerImages...), + Status: kapi.PodStatus{ + Phase: phase, + }, + } + + if ageInMinutes >= 0 { + pod.CreationTimestamp = util.NewTime(util.Now().Add(time.Duration(-1*ageInMinutes) * time.Minute)) + } + + return pod +} + +func podSpec(containerImages ...string) kapi.PodSpec { + spec := kapi.PodSpec{ + Containers: []kapi.Container{}, + } + for _, image := range containerImages { + container := kapi.Container{ + Image: image, + } + spec.Containers = append(spec.Containers, container) + } + return spec +} + +func streamList(streams ...imageapi.ImageStream) imageapi.ImageStreamList { + return imageapi.ImageStreamList{ + Items: streams, + } +} + +func stream(registry, namespace, name string, tags map[string]imageapi.TagEventList) imageapi.ImageStream { + return agedStream(registry, namespace, name, -1, tags) +} + +func agedStream(registry, namespace, name string, ageInMinutes int64, tags map[string]imageapi.TagEventList) imageapi.ImageStream { + stream := imageapi.ImageStream{ + ObjectMeta: kapi.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Status: imageapi.ImageStreamStatus{ + DockerImageRepository: fmt.Sprintf("%s/%s/%s", registry, namespace, name), + Tags: tags, + }, + } + + if ageInMinutes >= 0 { + stream.CreationTimestamp = util.NewTime(util.Now().Add(time.Duration(-1*ageInMinutes) * time.Minute)) + } + + return stream +} + +func streamPtr(registry, namespace, name string, tags map[string]imageapi.TagEventList) *imageapi.ImageStream { + s := stream(registry, namespace, name, tags) + return &s +} + +func tags(list ...namedTagEventList) map[string]imageapi.TagEventList { + m := make(map[string]imageapi.TagEventList, len(list)) + for _, tag := range list { + m[tag.name] = tag.events + } + return m +} + +type namedTagEventList struct { + name string + events imageapi.TagEventList +} + +func tag(name string, events ...imageapi.TagEvent) namedTagEventList { + return namedTagEventList{ + name: name, + events: imageapi.TagEventList{ + Items: events, + }, + } +} + +func tagEvent(id, ref string) imageapi.TagEvent { + return imageapi.TagEvent{ + Image: id, + DockerImageReference: ref, + } +} + +func rcList(rcs ...kapi.ReplicationController) kapi.ReplicationControllerList { + return kapi.ReplicationControllerList{ + Items: rcs, + } +} + +func rc(namespace, name string, containerImages ...string) kapi.ReplicationController { + return kapi.ReplicationController{ + ObjectMeta: kapi.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Spec: kapi.ReplicationControllerSpec{ + Template: &kapi.PodTemplateSpec{ + Spec: podSpec(containerImages...), + }, + }, + } +} + +func dcList(dcs ...deployapi.DeploymentConfig) deployapi.DeploymentConfigList { + return deployapi.DeploymentConfigList{ + Items: dcs, + } +} + +func dc(namespace, name string, containerImages ...string) deployapi.DeploymentConfig { + return deployapi.DeploymentConfig{ + ObjectMeta: kapi.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Template: deployapi.DeploymentTemplate{ + ControllerTemplate: kapi.ReplicationControllerSpec{ + Template: &kapi.PodTemplateSpec{ + Spec: podSpec(containerImages...), + }, + }, + }, + } +} + +func bcList(bcs ...buildapi.BuildConfig) buildapi.BuildConfigList { + return buildapi.BuildConfigList{ + Items: bcs, + } +} + +func bc(namespace, name string, strategyType buildapi.BuildStrategyType, fromKind, fromNamespace, fromName string) buildapi.BuildConfig { + return buildapi.BuildConfig{ + ObjectMeta: kapi.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Parameters: buildParameters(strategyType, fromKind, fromNamespace, fromName), + } +} + +func buildList(builds ...buildapi.Build) buildapi.BuildList { + return buildapi.BuildList{ + Items: builds, + } +} + +func build(namespace, name string, strategyType buildapi.BuildStrategyType, fromKind, fromNamespace, fromName string) buildapi.Build { + return buildapi.Build{ + ObjectMeta: kapi.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Parameters: buildParameters(strategyType, fromKind, fromNamespace, fromName), + } +} + +func buildParameters(strategyType buildapi.BuildStrategyType, fromKind, fromNamespace, fromName string) buildapi.BuildParameters { + params := buildapi.BuildParameters{ + Strategy: buildapi.BuildStrategy{ + Type: strategyType, + }, + } + switch strategyType { + case buildapi.SourceBuildStrategyType: + params.Strategy.SourceStrategy = &buildapi.SourceBuildStrategy{ + From: &kapi.ObjectReference{ + Kind: fromKind, + Namespace: fromNamespace, + Name: fromName, + }, + } + case buildapi.DockerBuildStrategyType: + params.Strategy.DockerStrategy = &buildapi.DockerBuildStrategy{ + From: &kapi.ObjectReference{ + Kind: fromKind, + Namespace: fromNamespace, + Name: fromName, + }, + } + case buildapi.CustomBuildStrategyType: + params.Strategy.CustomStrategy = &buildapi.CustomBuildStrategy{ + From: &kapi.ObjectReference{ + Kind: fromKind, + Namespace: fromNamespace, + Name: fromName, + }, + } + } + + return params +} + +var logLevel = flag.Int("loglevel", 0, "") +var testCase = flag.String("testcase", "", "") + +func TestImagePruning(t *testing.T) { + flag.Lookup("v").Value.Set(fmt.Sprint(*logLevel)) + registryURL := "registry" + + tests := map[string]struct { + registryURLs []string + images imageapi.ImageList + pods kapi.PodList + streams imageapi.ImageStreamList + rcs kapi.ReplicationControllerList + bcs buildapi.BuildConfigList + builds buildapi.BuildList + dcs deployapi.DeploymentConfigList + expectedDeletions []string + expectedUpdatedStreams []string + }{ + "1 pod - phase pending - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + pods: podList(pod("foo", "pod1", kapi.PodPending, registryURL+"/foo/bar@id")), + expectedDeletions: []string{}, + }, + "3 pods - last phase pending - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + pods: podList( + pod("foo", "pod1", kapi.PodSucceeded, registryURL+"/foo/bar@id"), + pod("foo", "pod2", kapi.PodSucceeded, registryURL+"/foo/bar@id"), + pod("foo", "pod3", kapi.PodPending, registryURL+"/foo/bar@id"), + ), + expectedDeletions: []string{}, + }, + "1 pod - phase running - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + pods: podList(pod("foo", "pod1", kapi.PodRunning, registryURL+"/foo/bar@id")), + expectedDeletions: []string{}, + }, + "3 pods - last phase running - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + pods: podList( + pod("foo", "pod1", kapi.PodSucceeded, registryURL+"/foo/bar@id"), + pod("foo", "pod2", kapi.PodSucceeded, registryURL+"/foo/bar@id"), + pod("foo", "pod3", kapi.PodRunning, registryURL+"/foo/bar@id"), + ), + expectedDeletions: []string{}, + }, + "pod phase succeeded - prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + pods: podList(pod("foo", "pod1", kapi.PodSucceeded, registryURL+"/foo/bar@id")), + expectedDeletions: []string{"id"}, + }, + "pod phase succeeded, pod less than min pruning age - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + pods: podList(agedPod("foo", "pod1", kapi.PodSucceeded, 5, registryURL+"/foo/bar@id")), + expectedDeletions: []string{}, + }, + "pod phase succeeded, image less than min pruning age - don't prune": { + images: imageList(agedImage("id", registryURL+"/foo/bar@id", 5)), + pods: podList(pod("foo", "pod1", kapi.PodSucceeded, registryURL+"/foo/bar@id")), + expectedDeletions: []string{}, + }, + "pod phase failed - prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + pods: podList( + pod("foo", "pod1", kapi.PodFailed, registryURL+"/foo/bar@id"), + pod("foo", "pod2", kapi.PodFailed, registryURL+"/foo/bar@id"), + pod("foo", "pod3", kapi.PodFailed, registryURL+"/foo/bar@id"), + ), + expectedDeletions: []string{"id"}, + }, + "pod phase unknown - prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + pods: podList( + pod("foo", "pod1", kapi.PodUnknown, registryURL+"/foo/bar@id"), + pod("foo", "pod2", kapi.PodUnknown, registryURL+"/foo/bar@id"), + pod("foo", "pod3", kapi.PodUnknown, registryURL+"/foo/bar@id"), + ), + expectedDeletions: []string{"id"}, + }, + "pod container image not parsable": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + pods: podList( + pod("foo", "pod1", kapi.PodRunning, "a/b/c/d/e"), + ), + expectedDeletions: []string{"id"}, + }, + "pod container image doesn't have an id": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + pods: podList( + pod("foo", "pod1", kapi.PodRunning, "foo/bar:latest"), + ), + expectedDeletions: []string{"id"}, + }, + "pod refers to image not in graph": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + pods: podList( + pod("foo", "pod1", kapi.PodRunning, registryURL+"/foo/bar@otherid"), + ), + expectedDeletions: []string{"id"}, + }, + "referenced by rc - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + rcs: rcList(rc("foo", "rc1", registryURL+"/foo/bar@id")), + expectedDeletions: []string{}, + }, + "referenced by dc - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + dcs: dcList(dc("foo", "rc1", registryURL+"/foo/bar@id")), + expectedDeletions: []string{}, + }, + "referenced by bc - sti - ImageStreamImage - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + bcs: bcList(bc("foo", "bc1", buildapi.SourceBuildStrategyType, "ImageStreamImage", "foo", "bar@id")), + expectedDeletions: []string{}, + }, + "referenced by bc - docker - ImageStreamImage - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + bcs: bcList(bc("foo", "bc1", buildapi.DockerBuildStrategyType, "ImageStreamImage", "foo", "bar@id")), + expectedDeletions: []string{}, + }, + "referenced by bc - custom - ImageStreamImage - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + bcs: bcList(bc("foo", "bc1", buildapi.CustomBuildStrategyType, "ImageStreamImage", "foo", "bar@id")), + expectedDeletions: []string{}, + }, + "referenced by bc - sti - DockerImage - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + bcs: bcList(bc("foo", "bc1", buildapi.SourceBuildStrategyType, "DockerImage", "foo", registryURL+"/foo/bar@id")), + expectedDeletions: []string{}, + }, + "referenced by bc - docker - DockerImage - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + bcs: bcList(bc("foo", "bc1", buildapi.DockerBuildStrategyType, "DockerImage", "foo", registryURL+"/foo/bar@id")), + expectedDeletions: []string{}, + }, + "referenced by bc - custom - DockerImage - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + bcs: bcList(bc("foo", "bc1", buildapi.CustomBuildStrategyType, "DockerImage", "foo", registryURL+"/foo/bar@id")), + expectedDeletions: []string{}, + }, + "referenced by build - sti - ImageStreamImage - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + builds: buildList(build("foo", "build1", buildapi.SourceBuildStrategyType, "ImageStreamImage", "foo", "bar@id")), + expectedDeletions: []string{}, + }, + "referenced by build - docker - ImageStreamImage - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + builds: buildList(build("foo", "build1", buildapi.DockerBuildStrategyType, "ImageStreamImage", "foo", "bar@id")), + expectedDeletions: []string{}, + }, + "referenced by build - custom - ImageStreamImage - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + builds: buildList(build("foo", "build1", buildapi.CustomBuildStrategyType, "ImageStreamImage", "foo", "bar@id")), + expectedDeletions: []string{}, + }, + "referenced by build - sti - DockerImage - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + builds: buildList(build("foo", "build1", buildapi.SourceBuildStrategyType, "DockerImage", "foo", registryURL+"/foo/bar@id")), + expectedDeletions: []string{}, + }, + "referenced by build - docker - DockerImage - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + builds: buildList(build("foo", "build1", buildapi.DockerBuildStrategyType, "DockerImage", "foo", registryURL+"/foo/bar@id")), + expectedDeletions: []string{}, + }, + "referenced by build - custom - DockerImage - don't prune": { + images: imageList(image("id", registryURL+"/foo/bar@id")), + builds: buildList(build("foo", "build1", buildapi.CustomBuildStrategyType, "DockerImage", "foo", registryURL+"/foo/bar@id")), + expectedDeletions: []string{}, + }, + "image stream - keep most recent n images": { + images: imageList( + unmanagedImage("id", "otherregistry/foo/bar@id", false, "", ""), + image("id2", registryURL+"/foo/bar@id2"), + image("id3", registryURL+"/foo/bar@id3"), + image("id4", registryURL+"/foo/bar@id4"), + ), + streams: streamList( + stream(registryURL, "foo", "bar", tags( + tag("latest", + tagEvent("id", "otherregistry/foo/bar@id"), + tagEvent("id2", registryURL+"/foo/bar@id2"), + tagEvent("id3", registryURL+"/foo/bar@id3"), + tagEvent("id4", registryURL+"/foo/bar@id4"), + ), + )), + ), + expectedDeletions: []string{"id4"}, + expectedUpdatedStreams: []string{"foo/bar"}, + }, + "image stream - same manifest listed multiple times in tag history": { + images: imageList( + image("id1", registryURL+"/foo/bar@id1"), + image("id2", registryURL+"/foo/bar@id2"), + ), + streams: streamList( + stream(registryURL, "foo", "bar", tags( + tag("latest", + tagEvent("id1", registryURL+"/foo/bar@id1"), + tagEvent("id2", registryURL+"/foo/bar@id2"), + tagEvent("id1", registryURL+"/foo/bar@id1"), + tagEvent("id2", registryURL+"/foo/bar@id2"), + ), + )), + ), + }, + "image stream age less than min pruning age - don't prune": { + images: imageList( + image("id", registryURL+"/foo/bar@id"), + image("id2", registryURL+"/foo/bar@id2"), + image("id3", registryURL+"/foo/bar@id3"), + image("id4", registryURL+"/foo/bar@id4"), + ), + streams: streamList( + agedStream(registryURL, "foo", "bar", 5, tags( + tag("latest", + tagEvent("id", registryURL+"/foo/bar@id"), + tagEvent("id2", registryURL+"/foo/bar@id2"), + tagEvent("id3", registryURL+"/foo/bar@id3"), + tagEvent("id4", registryURL+"/foo/bar@id4"), + ), + )), + ), + expectedDeletions: []string{}, + expectedUpdatedStreams: []string{}, + }, + "multiple resources pointing to image - don't prune": { + images: imageList( + image("id", registryURL+"/foo/bar@id"), + image("id2", registryURL+"/foo/bar@id2"), + ), + streams: streamList( + stream(registryURL, "foo", "bar", tags( + tag("latest", + tagEvent("id", registryURL+"/foo/bar@id"), + tagEvent("id2", registryURL+"/foo/bar@id2"), + ), + )), + ), + rcs: rcList(rc("foo", "rc1", registryURL+"/foo/bar@id2")), + pods: podList(pod("foo", "pod1", kapi.PodRunning, registryURL+"/foo/bar@id2")), + dcs: dcList(dc("foo", "rc1", registryURL+"/foo/bar@id")), + bcs: bcList(bc("foo", "bc1", buildapi.SourceBuildStrategyType, "DockerImage", "foo", registryURL+"/foo/bar@id")), + builds: buildList(build("foo", "build1", buildapi.CustomBuildStrategyType, "ImageStreamImage", "foo", "bar@id")), + expectedDeletions: []string{}, + expectedUpdatedStreams: []string{}, + }, + "image with nil annotations": { + images: imageList( + unmanagedImage("id", "someregistry/foo/bar@id", false, "", ""), + ), + expectedDeletions: []string{}, + expectedUpdatedStreams: []string{}, + }, + "image missing managed annotation": { + images: imageList( + unmanagedImage("id", "someregistry/foo/bar@id", true, "foo", "bar"), + ), + expectedDeletions: []string{}, + expectedUpdatedStreams: []string{}, + }, + "image with managed annotation != true": { + images: imageList( + unmanagedImage("id", "someregistry/foo/bar@id", true, imageapi.ManagedByOpenShiftAnnotation, "false"), + unmanagedImage("id", "someregistry/foo/bar@id", true, imageapi.ManagedByOpenShiftAnnotation, "0"), + unmanagedImage("id", "someregistry/foo/bar@id", true, imageapi.ManagedByOpenShiftAnnotation, "1"), + unmanagedImage("id", "someregistry/foo/bar@id", true, imageapi.ManagedByOpenShiftAnnotation, "True"), + unmanagedImage("id", "someregistry/foo/bar@id", true, imageapi.ManagedByOpenShiftAnnotation, "yes"), + unmanagedImage("id", "someregistry/foo/bar@id", true, imageapi.ManagedByOpenShiftAnnotation, "Yes"), + ), + expectedDeletions: []string{}, + expectedUpdatedStreams: []string{}, + }, + "image with bad manifest is pruned ok": { + images: imageList( + imageWithBadManifest("id", "someregistry/foo/bar@id"), + ), + expectedDeletions: []string{"id"}, + expectedUpdatedStreams: []string{}, + }, + } + + for name, test := range tests { + tcFilter := flag.Lookup("testcase").Value.String() + if len(tcFilter) > 0 && name != tcFilter { + continue + } + p := NewImagePruner(60*time.Minute, 3, &test.images, &test.streams, &test.pods, &test.rcs, &test.bcs, &test.builds, &test.dcs) + actualDeletions := util.NewStringSet() + actualUpdatedStreams := util.NewStringSet() + + pruneImage := func(image *imageapi.Image) error { + actualDeletions.Insert(image.Name) + return nil + } + + pruneStream := func(stream *imageapi.ImageStream, image *imageapi.Image) (*imageapi.ImageStream, error) { + actualUpdatedStreams.Insert(fmt.Sprintf("%s/%s", stream.Namespace, stream.Name)) + return stream, nil + } + + pruneLayer := func(registryURL, repo, layer string) error { + return nil + } + + pruneBlob := func(registryURL, blob string) error { + return nil + } + + pruneManifest := func(registryURL, repo, manifest string) error { + return nil + } + + p.Run(pruneImage, pruneStream, pruneLayer, pruneBlob, pruneManifest) + + expectedDeletions := util.NewStringSet(test.expectedDeletions...) + if !reflect.DeepEqual(expectedDeletions, actualDeletions) { + t.Errorf("%s: expected image deletions %q, got %q", name, expectedDeletions.List(), actualDeletions.List()) + } + + expectedUpdatedStreams := util.NewStringSet(test.expectedUpdatedStreams...) + if !reflect.DeepEqual(expectedUpdatedStreams, actualUpdatedStreams) { + t.Errorf("%s: expected stream updates %q, got %q", name, expectedUpdatedStreams.List(), actualUpdatedStreams.List()) + } + } +} + +func TestDeletingImagePruneFunc(t *testing.T) { + flag.Lookup("v").Value.Set(fmt.Sprint(*logLevel)) + + tests := map[string]struct { + imageDeletionError error + }{ + "no error": {}, + "delete error": { + imageDeletionError: fmt.Errorf("foo"), + }, + } + + for name, test := range tests { + imageClient := client.Fake{ + Err: test.imageDeletionError, + } + pruneFunc := DeletingImagePruneFunc(imageClient.Images()) + err := pruneFunc(&imageapi.Image{ObjectMeta: kapi.ObjectMeta{Name: "id2"}}) + if test.imageDeletionError != nil { + if e, a := fmt.Sprintf("Error deleting image: %v", test.imageDeletionError), err.Error(); e != a { + t.Errorf("%s: err: expected %v, got %v", name, e, a) + } + continue + } + + if e, a := 1, len(imageClient.Actions); e != a { + t.Errorf("%s: expected %d actions, got %d: %#v", name, e, a, imageClient.Actions) + continue + } + + if e, a := "delete-image", imageClient.Actions[0].Action; e != a { + t.Errorf("%s: expected action %q, got %q", name, e, a) + } + } +} + +func TestRegistryPruning(t *testing.T) { + flag.Lookup("v").Value.Set(fmt.Sprint(*logLevel)) + + tests := map[string]struct { + images imageapi.ImageList + streams imageapi.ImageStreamList + expectedLayerDeletions util.StringSet + expectedBlobDeletions util.StringSet + expectedManifestDeletions util.StringSet + }{ + "layers unique to id1 pruned": { + images: imageList( + imageWithLayers("id1", "registry1/foo/bar@id1", "layer1", "layer2", "layer3", "layer4"), + imageWithLayers("id2", "registry1/foo/bar@id2", "layer3", "layer4", "layer5", "layer6"), + ), + streams: streamList( + stream("registry1", "foo", "bar", tags( + tag("latest", + tagEvent("id2", "registry1/foo/bar@id2"), + tagEvent("id1", "registry1/foo/bar@id1"), + ), + )), + stream("registry1", "foo", "other", tags( + tag("latest", + tagEvent("id2", "registry1/foo/other@id2"), + ), + )), + stream("registry2", "foo", "bar", tags( + tag("latest", + tagEvent("id2", "registry2/foo/bar@id2"), + tagEvent("id1", "registry2/foo/bar@id1"), + ), + )), + stream("registry2", "foo", "other", tags( + tag("latest", + tagEvent("id2", "registry2/foo/other@id2"), + ), + )), + ), + expectedLayerDeletions: util.NewStringSet( + "registry1|foo/bar|layer1", + "registry1|foo/bar|layer2", + "registry2|foo/bar|layer1", + "registry2|foo/bar|layer2", + ), + expectedBlobDeletions: util.NewStringSet( + "registry1|layer1", + "registry1|layer2", + "registry2|layer1", + "registry2|layer2", + ), + expectedManifestDeletions: util.NewStringSet( + "registry1|foo/bar|id1", + "registry2|foo/bar|id1", + ), + }, + "no pruning when no images are pruned": { + images: imageList( + imageWithLayers("id1", "registry1/foo/bar@id1", "layer1", "layer2", "layer3", "layer4"), + ), + streams: streamList( + stream("registry1", "foo", "bar", tags( + tag("latest", + tagEvent("id1", "registry1/foo/bar@id1"), + ), + )), + ), + expectedLayerDeletions: util.NewStringSet(), + expectedBlobDeletions: util.NewStringSet(), + expectedManifestDeletions: util.NewStringSet(), + }, + } + + for name, test := range tests { + t.Logf("Running test case %s", name) + actualLayerDeletions := util.NewStringSet() + actualBlobDeletions := util.NewStringSet() + actualManifestDeletions := util.NewStringSet() + + pruneImage := func(image *imageapi.Image) error { + return nil + } + + pruneStream := func(stream *imageapi.ImageStream, image *imageapi.Image) (*imageapi.ImageStream, error) { + return stream, nil + } + + pruneLayer := func(registryURL, repo, layer string) error { + actualLayerDeletions.Insert(fmt.Sprintf("%s|%s|%s", registryURL, repo, layer)) + return nil + } + + pruneBlob := func(registryURL, blob string) error { + actualBlobDeletions.Insert(fmt.Sprintf("%s|%s", registryURL, blob)) + return nil + } + + pruneManifest := func(registryURL, repo, manifest string) error { + actualManifestDeletions.Insert(fmt.Sprintf("%s|%s|%s", registryURL, repo, manifest)) + return nil + } + + p := NewImagePruner(60, 1, &test.images, &test.streams, &kapi.PodList{}, &kapi.ReplicationControllerList{}, &buildapi.BuildConfigList{}, &buildapi.BuildList{}, &deployapi.DeploymentConfigList{}) + + p.Run(pruneImage, pruneStream, pruneLayer, pruneBlob, pruneManifest) + + if !reflect.DeepEqual(test.expectedLayerDeletions, actualLayerDeletions) { + t.Errorf("%s: expected layer deletions %#v, got %#v", name, test.expectedLayerDeletions, actualLayerDeletions) + } + if !reflect.DeepEqual(test.expectedBlobDeletions, actualBlobDeletions) { + t.Errorf("%s: expected blob deletions %#v, got %#v", name, test.expectedBlobDeletions, actualBlobDeletions) + } + if !reflect.DeepEqual(test.expectedManifestDeletions, actualManifestDeletions) { + t.Errorf("%s: expected manifest deletions %#v, got %#v", name, test.expectedManifestDeletions, actualManifestDeletions) + } + } +} diff --git a/pkg/image/registry/imagestreamimage/rest.go b/pkg/image/registry/imagestreamimage/rest.go index 00ed888ffb0a..ca058ad0c42f 100644 --- a/pkg/image/registry/imagestreamimage/rest.go +++ b/pkg/image/registry/imagestreamimage/rest.go @@ -35,9 +35,9 @@ func (r *REST) New() runtime.Object { return &api.ImageStreamImage{} } -// nameAndID splits a string into its name component and ID component, and returns an error +// ParseNameAndID splits a string into its name component and ID component, and returns an error // if the string is not in the right form. -func nameAndID(input string) (name string, id string, err error) { +func ParseNameAndID(input string) (name string, id string, err error) { segments := strings.Split(input, "@") switch len(segments) { case 2: @@ -55,7 +55,7 @@ func nameAndID(input string) (name string, id string, err error) { // Get retrieves an image by ID that has previously been tagged into an image stream. // `id` is of the form @. func (r *REST) Get(ctx kapi.Context, id string) (runtime.Object, error) { - name, imageID, err := nameAndID(id) + name, imageID, err := ParseNameAndID(id) if err != nil { return nil, err } diff --git a/pkg/image/registry/imagestreamimage/rest_test.go b/pkg/image/registry/imagestreamimage/rest_test.go index 7b5b7fd38aad..e351100ff2b8 100644 --- a/pkg/image/registry/imagestreamimage/rest_test.go +++ b/pkg/image/registry/imagestreamimage/rest_test.go @@ -76,7 +76,7 @@ func TestNameAndID(t *testing.T) { } for name, test := range tests { - repo, id, err := nameAndID(test.input) + repo, id, err := ParseNameAndID(test.input) didError := err != nil if e, a := test.expectError, didError; e != a { t.Fatalf("%s: expected error=%t, got=%t: %s", name, e, a, err)