Remove namespace & repo opts form push and bump v2
- As a result of removing namespace from push command, the repo was also suppressed. - Bump of the metadata schema do v0.2 accepting any formatting on `name` and removing `namespace` Signed-off-by: Ulysses Souza <ulysses.souza@docker.com>
This commit is contained in:
Родитель
245b591a8d
Коммит
6688c3b286
|
@ -21,8 +21,7 @@ import (
|
|||
)
|
||||
|
||||
type bundleOptions struct {
|
||||
invocationImageName string
|
||||
out string
|
||||
out string
|
||||
}
|
||||
|
||||
func bundleCmd(dockerCli command.Cli) *cobra.Command {
|
||||
|
@ -36,13 +35,12 @@ func bundleCmd(dockerCli command.Cli) *cobra.Command {
|
|||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.invocationImageName, "invocation-image", "i", "", "specify the name of invocation image to build")
|
||||
cmd.Flags().StringVarP(&opts.out, "out", "o", "bundle.json", "path to the output bundle.json (- for stdout)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runBundle(dockerCli command.Cli, appName string, opts bundleOptions) error {
|
||||
bundle, err := makeBundle(dockerCli, appName, opts.invocationImageName)
|
||||
bundle, err := makeBundle(dockerCli, appName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -61,22 +59,22 @@ func runBundle(dockerCli command.Cli, appName string, opts bundleOptions) error
|
|||
return ioutil.WriteFile(opts.out, bundleBytes, 0644)
|
||||
}
|
||||
|
||||
func makeBundle(dockerCli command.Cli, appName, invocationImageName string) (*bundle.Bundle, error) {
|
||||
func makeBundle(dockerCli command.Cli, appName string) (*bundle.Bundle, error) {
|
||||
app, err := packager.Extract(appName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer app.Cleanup()
|
||||
return makeBundleFromApp(dockerCli, app, invocationImageName)
|
||||
return makeBundleFromApp(dockerCli, app)
|
||||
}
|
||||
|
||||
func makeBundleFromApp(dockerCli command.Cli, app *types.App, invocationImageName string) (*bundle.Bundle, error) {
|
||||
func makeBundleFromApp(dockerCli command.Cli, app *types.App) (*bundle.Bundle, error) {
|
||||
meta := app.Metadata()
|
||||
invocationImageName, err := makeImageName(meta, invocationImageName, "-invoc")
|
||||
invocationImageName, err := makeImageName(meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := makeImageName(app.Metadata(), "", ""); err != nil {
|
||||
if _, err := makeImageName(meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
@ -100,12 +98,10 @@ func makeBundleFromApp(dockerCli command.Cli, app *types.App, invocationImageNam
|
|||
return packager.ToCNAB(app, invocationImageName)
|
||||
}
|
||||
|
||||
func makeImageName(meta metadata.AppMetadata, name, suffix string) (string, error) {
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s:%s%s", meta.Name, meta.Version, suffix)
|
||||
}
|
||||
func makeImageName(meta metadata.AppMetadata) (string, error) {
|
||||
name := fmt.Sprintf("%s:%s-invoc", meta.Name, meta.Version)
|
||||
if _, err := reference.ParseNormalizedNamed(name); err != nil {
|
||||
return "", errors.Wrapf(err, "image name %q is invalid, please check namespace, name and version fields", name)
|
||||
return "", errors.Wrapf(err, "image name %q is invalid, please check name and version fields", name)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
|
|
@ -9,42 +9,16 @@ import (
|
|||
|
||||
func TestMakeInvocationImage(t *testing.T) {
|
||||
testcases := []struct {
|
||||
name string
|
||||
imageName string
|
||||
meta metadata.AppMetadata
|
||||
expected string
|
||||
err string
|
||||
name string
|
||||
meta metadata.AppMetadata
|
||||
expected string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "specify-image-name",
|
||||
imageName: "my-invocation-image",
|
||||
expected: "my-invocation-image",
|
||||
},
|
||||
{
|
||||
name: "specify-image-name-and-namespace",
|
||||
imageName: "my-invocation-image",
|
||||
expected: "my-invocation-image",
|
||||
},
|
||||
{
|
||||
name: "simple-metadata",
|
||||
meta: metadata.AppMetadata{Name: "name", Version: "version"},
|
||||
expected: "name:version-invoc",
|
||||
},
|
||||
{
|
||||
name: "simple-metadata-with-overridden-namespace",
|
||||
meta: metadata.AppMetadata{Name: "name", Version: "version"},
|
||||
expected: "name:version-invoc",
|
||||
},
|
||||
{
|
||||
name: "metadata-with-namespace",
|
||||
meta: metadata.AppMetadata{Name: "name", Version: "version"},
|
||||
expected: "name:version-invoc",
|
||||
},
|
||||
{
|
||||
name: "metadata-with-namespace-and-overridden-namespace",
|
||||
meta: metadata.AppMetadata{Name: "name", Version: "version"},
|
||||
expected: "name:version-invoc",
|
||||
},
|
||||
{
|
||||
name: "simple-metadata",
|
||||
meta: metadata.AppMetadata{Name: "WrongName&%*", Version: "version"},
|
||||
|
@ -53,10 +27,10 @@ func TestMakeInvocationImage(t *testing.T) {
|
|||
}
|
||||
for _, c := range testcases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
actual, err := makeImageName(c.meta, c.imageName, "-invoc")
|
||||
actual, err := makeImageName(c.meta)
|
||||
if c.err != "" {
|
||||
assert.ErrorContains(t, err, c.err)
|
||||
assert.Equal(t, actual, "")
|
||||
assert.Equal(t, actual, "", "On "+c.meta.Name)
|
||||
} else {
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, actual, c.expected)
|
||||
|
|
|
@ -123,7 +123,7 @@ func extractAndLoadAppBasedBundle(dockerCli command.Cli, name string) (*bundle.B
|
|||
return nil, err
|
||||
}
|
||||
defer app.Cleanup()
|
||||
return makeBundleFromApp(dockerCli, app, "")
|
||||
return makeBundleFromApp(dockerCli, app)
|
||||
}
|
||||
|
||||
func resolveBundle(dockerCli command.Cli, name string) (*bundle.Bundle, error) {
|
||||
|
|
|
@ -27,7 +27,7 @@ func inspectCmd(dockerCli command.Cli) *cobra.Command {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bundle, err := resolveBundle(dockerCli, "", appname)
|
||||
bundle, err := resolveBundle(dockerCli, appname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -9,9 +9,7 @@ import (
|
|||
)
|
||||
|
||||
type pushOptions struct {
|
||||
namespace string
|
||||
tag string
|
||||
repo string
|
||||
tag string
|
||||
}
|
||||
|
||||
func pushCmd() *cobra.Command {
|
||||
|
@ -26,15 +24,13 @@ func pushCmd() *cobra.Command {
|
|||
return err
|
||||
}
|
||||
defer app.Cleanup()
|
||||
dgst, err := packager.Push(app, opts.namespace, opts.tag, opts.repo)
|
||||
dgst, err := packager.Push(app, opts.tag)
|
||||
if err == nil {
|
||||
fmt.Println(dgst)
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.namespace, "namespace", "", "Namespace to use")
|
||||
cmd.Flags().StringVarP(&opts.tag, "tag", "t", "", "Tag to use (default: version in metadata)")
|
||||
cmd.Flags().StringVar(&opts.repo, "repo", "", "Name of the remote repository (default: <app-name>.dockerapp)")
|
||||
cmd.Flags().StringVarP(&opts.tag, "tag", "t", "", "Target registry reference (default is : from metadata)")
|
||||
return cmd
|
||||
}
|
||||
|
|
|
@ -241,10 +241,10 @@ func TestBundle(t *testing.T) {
|
|||
|
||||
// List the images on the build context daemon and checks the invocation image is there
|
||||
cmd.Command = []string{dockerCli, "image", "ls", "--format", "{{.Repository}}:{{.Tag}}"}
|
||||
icmd.RunCmd(cmd).Assert(t, icmd.Expected{ExitCode: 0, Out: "acmecorp/simple:1.1.0-beta1-invoc"})
|
||||
icmd.RunCmd(cmd).Assert(t, icmd.Expected{ExitCode: 0, Out: "simple:1.1.0-beta1-invoc"})
|
||||
|
||||
// Copy all the files from the invocation image and check them
|
||||
cmd.Command = []string{dockerCli, "create", "--name", "invocation", "acmecorp/simple:1.1.0-beta1-invoc"}
|
||||
cmd.Command = []string{dockerCli, "create", "--name", "invocation", "simple:1.1.0-beta1-invoc"}
|
||||
id := strings.TrimSpace(icmd.RunCmd(cmd).Assert(t, icmd.Success).Stdout())
|
||||
cmd.Command = []string{dockerCli, "cp", "invocation:/cnab/app/simple.dockerapp", tmpDir.Join("simple.dockerapp")}
|
||||
icmd.RunCmd(cmd).Assert(t, icmd.Success)
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
version: 0.1.0
|
||||
name: myapp
|
||||
description: ""
|
||||
namespace: "alice"
|
||||
maintainers:
|
||||
- name: bearclaw
|
||||
email: bearclaw@bearclaw.bearclaw
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "acmecorp/simple",
|
||||
"name": "simple",
|
||||
"version": "1.1.0-beta1",
|
||||
"description": "new fancy webapp with microservices",
|
||||
"maintainers": [
|
||||
|
@ -17,7 +17,7 @@
|
|||
"invocationImages": [
|
||||
{
|
||||
"imageType": "docker",
|
||||
"image": "acmecorp/simple:1.1.0-beta1-invoc"
|
||||
"image": "simple:1.1.0-beta1-invoc"
|
||||
}
|
||||
],
|
||||
"images": {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
version: 1.1.0-beta1
|
||||
name: simple
|
||||
namespace: acmecorp
|
||||
description: "new fancy webapp with microservices"
|
||||
maintainers:
|
||||
- name: John Developer
|
||||
|
|
|
@ -4,8 +4,6 @@ version: 0.1.0
|
|||
name: voting-app
|
||||
# A short description of the application
|
||||
description: "Dogs or cats?"
|
||||
# Namespace to use when pushing to a registry. This is typically your Hub username.
|
||||
namespace: myhubusername
|
||||
# List of application maintainers with name and email for each
|
||||
maintainers:
|
||||
- name: user
|
||||
|
|
|
@ -4,5 +4,5 @@ var (
|
|||
// Experimental enables experimental features if set to "on"
|
||||
Experimental = "on"
|
||||
// MetadataVersion defines the current schema version
|
||||
MetadataVersion = "v0.1"
|
||||
MetadataVersion = "v0.2"
|
||||
)
|
||||
|
|
|
@ -85,26 +85,20 @@ func ExtractImagePayloadToDiskFiles(appDir string, payload map[string]string) er
|
|||
}
|
||||
|
||||
// Push pushes an app to a registry. Returns the image digest.
|
||||
func Push(app *types.App, namespace, tag, repo string) (string, error) {
|
||||
func Push(app *types.App, tag string) (string, error) {
|
||||
payload, err := createPayload(app)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed to read external file while creating payload for push")
|
||||
}
|
||||
imageName := createImageName(app, namespace, tag, repo)
|
||||
imageName := createImageName(app, tag)
|
||||
return resto.PushConfigMulti(context.Background(), payload, imageName, resto.RegistryOptions{}, nil)
|
||||
}
|
||||
|
||||
func createImageName(app *types.App, namespace, tag, repo string) string {
|
||||
if tag == "" {
|
||||
tag = app.Metadata().Version
|
||||
func createImageName(app *types.App, registryReference string) string {
|
||||
if registryReference != "" {
|
||||
return registryReference
|
||||
}
|
||||
if repo == "" {
|
||||
repo = internal.AppNameFromDir(app.Name) + internal.AppExtension
|
||||
}
|
||||
if namespace != "" && namespace[len(namespace)-1] != '/' {
|
||||
namespace += "/"
|
||||
}
|
||||
return namespace + repo + ":" + tag
|
||||
return app.Metadata().Name + ":" + app.Metadata().Version
|
||||
}
|
||||
|
||||
func createPayload(app *types.App) (map[string]string, error) {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
version: 0.1.0
|
||||
name: packing
|
||||
namespace: my-namespace
|
||||
description: "hello"
|
||||
maintainers:
|
||||
- name: bearclaw
|
||||
|
|
|
@ -6,6 +6,8 @@ import (
|
|||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
|
@ -100,7 +102,24 @@ func (f *_escFile) Close() error {
|
|||
}
|
||||
|
||||
func (f *_escFile) Readdir(count int) ([]os.FileInfo, error) {
|
||||
return nil, nil
|
||||
if !f.isDir {
|
||||
return nil, fmt.Errorf(" escFile.Readdir: '%s' is not directory", f.name)
|
||||
}
|
||||
|
||||
fis, ok := _escDirs[f.local]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(" escFile.Readdir: '%s' is directory, but we have no info about content of this dir, local=%s", f.name, f.local)
|
||||
}
|
||||
limit := count
|
||||
if count <= 0 || limit > len(fis) {
|
||||
limit = len(fis)
|
||||
}
|
||||
|
||||
if len(fis) == 0 && count > 0 {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
return []os.FileInfo(fis[0:limit]), nil
|
||||
}
|
||||
|
||||
func (f *_escFile) Stat() (os.FileInfo, error) {
|
||||
|
@ -191,25 +210,48 @@ func _escFSMustString(useLocal bool, name string) string {
|
|||
var _escData = map[string]*_escFile{
|
||||
|
||||
"/schemas/metadata_schema_v0.1.json": {
|
||||
name: "metadata_schema_v0.1.json",
|
||||
local: "schemas/metadata_schema_v0.1.json",
|
||||
size: 1019,
|
||||
size: 1916,
|
||||
modtime: 1518458244,
|
||||
compressed: `
|
||||
H4sIAAAAAAAC/5xSy27CQAy85ytW2x6BUKmn/AqqkJsYWJR91GuQUJV/rxLz2nYTUHNKxvZkxuPvQiml
|
||||
9Gusd2hBV0rvmENVlvvo3VzQhadt2RBseL58LwV70TOZNE0/ZJGhAYa1VNfH5eJt0VNc2vgUsG/0n3us
|
||||
+YIG8gGJDUZdKZEy4A4sJkjCEZmM2545rtWNJws8OPCRB4ZrQ3fr1UekaLx7SJ8dbjDWZAJPEawSdKhk
|
||||
FYvTQ9vqBP7I/rj3EwPU+D/dFoxjMA4pjhMAEZx+r9Uw2r8zU+ml/nJJPiX//ulmeXa0YNqn6FejHdMB
|
||||
PQgrCW58+u44RXLeZTGN3L7k7bwVTfh1MIRNYlM2n7n7Qo6sK34CAAD//7xaWqb7AwAA
|
||||
H4sIAAAAAAAC/7RUwY7iMAy99yuqsEegrLQnfgUh5G1dCKJJxjFIaMS/j9pAaaZpWoG4OvbLs/38vpM0
|
||||
TVPxx+YHrECsU3FgNussO1qtFi661LTPCoKSF6t/mYvNxNxVyqIuqpChAIade91dVsu/yxrikcZXg3Wi
|
||||
/n/EnB9RQ9ogsUQr1qmj0sQVVOhFPAzLJNX+jtG+lpoq4KYDbblBaBNuz1xxQbJSq1H4YHGBNidpOAaw
|
||||
8aLNS5Cx6/R8OgkvvA1+XPdjDeT4Gu8KpGKQCskOAwARXH+PVTJW/RonGsKyrptlBZZSyXoqNnt+5fd1
|
||||
CxIzQKj446TcN4OEkg4tQfh1loSFt0onyYCMmsj2Xtr50hd0Zyi9Tt0FDQ5xHp6Ld0jPcYYPKn5Yo0oK
|
||||
LK6twQrkaRRyE3yN30bkRtpbCVd1vMDR63cyWZrT9nXP/eQ2Rlvt215sb8OG8pYchtz1LdCYe00yjAnG
|
||||
8aKrhQUVlZgzm+SW/AQAAP//m8slr3wHAAA=
|
||||
`,
|
||||
},
|
||||
|
||||
"/": {
|
||||
isDir: true,
|
||||
local: "",
|
||||
"/schemas/metadata_schema_v0.2.json": {
|
||||
name: "metadata_schema_v0.2.json",
|
||||
local: "schemas/metadata_schema_v0.2.json",
|
||||
size: 1732,
|
||||
modtime: 1518458244,
|
||||
compressed: `
|
||||
H4sIAAAAAAAC/7RUzY7yMAy89ymq8B0LRZ/2xKsghLzUBSOSdB2DhFa8+6oNf9mmP2LFdZyxx/bE30ma
|
||||
pqn65zY71KAWqdqJVIs83ztrph6dWd7mBUMp0/lH7rGJyjyTipqkUaAAgbWPrk/z2f9ZneL2TM4V1g/t
|
||||
5x43ckMrthWyEDq1SL2UBjegMUCCHE6YzFbdg5fswTwhO7LmNXKBbsNUSV+CZYA2kWvKrB0xx8NBBfAq
|
||||
WlgDGQEyyK5bOTDD+VcVRYK6zfE7ZSxr3iQvsCRDdVsuf5QKhV2iwipgNPJ2Ub5Mp6DkSZZi/DoSYxHs
|
||||
wjsm4oMGWV2pTyVDvz0NpdWpN3jnELP4XAKfP8YZ93u/7wctHFncnYMa6DCYchmN9pu7x+R3s8dZqrSs
|
||||
QepWvLx2J6OtOW5f17fv3MZgqzvrpMk4am9dd+xPZug7M6N+9ogf/uL5iW++1wv+KiSX5CcAAP//f4Kg
|
||||
RsQGAAA=
|
||||
`,
|
||||
},
|
||||
|
||||
"/schemas": {
|
||||
name: "schemas",
|
||||
local: `schemas`,
|
||||
isDir: true,
|
||||
local: "schemas",
|
||||
},
|
||||
}
|
||||
|
||||
var _escDirs = map[string][]os.FileInfo{
|
||||
|
||||
"schemas": {
|
||||
_escData["/schemas/metadata_schema_v0.1.json"],
|
||||
_escData["/schemas/metadata_schema_v0.2.json"],
|
||||
},
|
||||
}
|
||||
|
|
|
@ -14,9 +14,8 @@ func TestValidateInvalidMetadata(t *testing.T) {
|
|||
metadata := map[string]interface{}{
|
||||
"name": "_INVALID",
|
||||
}
|
||||
assert.Error(t, Validate(metadata, "v0.1"),
|
||||
`- name: Does not match format 'hostname'
|
||||
- version: version is required`)
|
||||
assert.Error(t, Validate(metadata, "v0.2"),
|
||||
`- version: version is required`)
|
||||
}
|
||||
|
||||
func TestValidateMetadata(t *testing.T) {
|
||||
|
@ -24,5 +23,31 @@ func TestValidateMetadata(t *testing.T) {
|
|||
"name": "my-name",
|
||||
"version": "my-version",
|
||||
}
|
||||
assert.NilError(t, Validate(metadata, "v0.1"))
|
||||
assert.NilError(t, Validate(metadata, "v0.2"))
|
||||
}
|
||||
|
||||
func TestValidateMetadataNoName(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
//"name": "my-name",
|
||||
// MUST fail! No name
|
||||
"version": "my-version",
|
||||
}
|
||||
assert.Error(t, Validate(metadata, "v0.2"), "- name: name is required")
|
||||
}
|
||||
|
||||
func TestValidateMetadataNoVersion(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"name": "my-name",
|
||||
//"version": "my-version",
|
||||
// MUST fail! No version
|
||||
}
|
||||
assert.Error(t, Validate(metadata, "v0.2"), "- version: version is required")
|
||||
}
|
||||
|
||||
func TestValidateMetadataV0_2(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"name": "my-name",
|
||||
"version": "my-version",
|
||||
}
|
||||
assert.NilError(t, Validate(metadata, "v0.2"))
|
||||
}
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"id": "metadata_schema_v0.2.json",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"maintainers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/maintainer"
|
||||
}
|
||||
},
|
||||
"parents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/parent"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"version"
|
||||
],
|
||||
"definitions": {
|
||||
"maintainer": {
|
||||
"id": "#/definitions/maintainer",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"id": "#/definitions/parent",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"format": "hostname"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"maintainers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/maintainer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -216,8 +216,7 @@ func TestWithAttachmentsIncludingNestedCoreFiles(t *testing.T) {
|
|||
|
||||
func TestValidateBrokenMetadata(t *testing.T) {
|
||||
r := strings.NewReader(`#version: 0.1.0-missing
|
||||
name: _INVALID-name
|
||||
namespace: myhubusername
|
||||
name: MustBeAValidUntaggedRegistryReferenceButNotEvaluatedByTheSchema
|
||||
maintainers:
|
||||
- name: user
|
||||
email: user@email.com
|
||||
|
@ -229,7 +228,6 @@ unknown: property`)
|
|||
err := Metadata(r)(app)
|
||||
assert.Error(t, err, `failed to validate metadata:
|
||||
- maintainers.2.email: Does not match format 'email'
|
||||
- name: Does not match format 'hostname'
|
||||
- version: version is required`)
|
||||
}
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче