From 28e982fffbea76ea9b372bd5b238b07eac8c7c7c Mon Sep 17 00:00:00 2001 From: Lauris BH Date: Wed, 3 Jul 2024 16:33:11 +0300 Subject: [PATCH] Global and organization registries (#1672) Co-authored-by: Anbraten <6918444+anbraten@users.noreply.github.com> --- cli/admin/admin.go | 30 ++ cli/{ => admin}/registry/registry.go | 4 +- cli/admin/registry/registry_add.go | 75 +++ cli/admin/registry/registry_info.go | 62 +++ cli/admin/registry/registry_list.go | 65 +++ cli/admin/registry/registry_rm.go | 45 ++ cli/admin/registry/registry_set.go | 78 ++++ cli/org/org.go | 30 ++ cli/org/registry/registry.go | 60 +++ cli/org/registry/registry_add.go | 83 ++++ cli/org/registry/registry_info.go | 69 +++ cli/org/registry/registry_list.go | 72 +++ cli/org/registry/registry_rm.go | 53 +++ cli/org/registry/registry_set.go | 84 ++++ cli/repo/registry/registry.go | 44 ++ cli/{ => repo}/registry/registry_add.go | 23 +- cli/{ => repo}/registry/registry_info.go | 14 +- cli/{ => repo}/registry/registry_list.go | 14 +- cli/{ => repo}/registry/registry_rm.go | 13 +- cli/{ => repo}/registry/registry_set.go | 22 +- cli/repo/repo.go | 3 + cmd/cli/app.go | 9 +- cmd/server/docs/docs.go | 432 +++++++++++++++++- pipeline/rpc/proto/woodpecker.pb.go | 44 +- server/api/global_registry.go | 170 +++++++ server/api/org_registry.go | 207 +++++++++ server/api/registry.go | 26 +- server/model/registry.go | 23 +- server/pipeline/items.go | 4 +- server/router/api.go | 29 ++ server/services/registry/combined.go | 132 +++++- server/services/registry/db.go | 93 +++- server/services/registry/filesystem.go | 19 +- server/services/registry/service.go | 18 +- server/services/secret/db.go | 4 +- server/services/secret/db_test.go | 6 +- server/services/secret/service.go | 2 +- .../migration/032_registries_add_user.go | 33 ++ server/store/datastore/migration/common.go | 1 + server/store/datastore/migration/migration.go | 1 + server/store/datastore/registry.go | 48 +- server/store/datastore/registry_test.go | 89 +++- server/store/mocks/store.go | 178 +++++++- server/store/store.go | 9 +- web/components.d.ts | 6 +- web/src/assets/locales/en.json | 45 +- .../admin/settings/AdminRegistriesTab.vue | 101 ++++ web/src/components/layout/scaffold/Tabs.vue | 2 +- .../org/settings/OrgRegistriesTab.vue | 113 +++++ web/src/components/registry/RegistryEdit.vue | 78 ++++ web/src/components/registry/RegistryList.vue | 63 +++ .../repo/settings/RegistriesTab.vue | 107 ++--- web/src/lib/api/index.ts | 42 +- web/src/lib/api/types/registry.ts | 3 + web/src/views/admin/AdminSettings.vue | 4 + web/src/views/org/OrgSettings.vue | 5 + web/src/views/repo/RepoSettings.vue | 18 +- web/src/views/repo/RepoWrapper.vue | 4 +- .../views/repo/pipeline/PipelineWrapper.vue | 12 +- woodpecker-go/woodpecker/global_registry.go | 46 ++ woodpecker-go/woodpecker/interface.go | 30 ++ woodpecker-go/woodpecker/mocks/client.go | 276 +++++++++++ woodpecker-go/woodpecker/org.go | 48 +- woodpecker-go/woodpecker/repo.go | 4 +- woodpecker-go/woodpecker/types.go | 2 + 65 files changed, 3260 insertions(+), 269 deletions(-) create mode 100644 cli/admin/admin.go rename cli/{ => admin}/registry/registry.go (92%) create mode 100644 cli/admin/registry/registry_add.go create mode 100644 cli/admin/registry/registry_info.go create mode 100644 cli/admin/registry/registry_list.go create mode 100644 cli/admin/registry/registry_rm.go create mode 100644 cli/admin/registry/registry_set.go create mode 100644 cli/org/org.go create mode 100644 cli/org/registry/registry.go create mode 100644 cli/org/registry/registry_add.go create mode 100644 cli/org/registry/registry_info.go create mode 100644 cli/org/registry/registry_list.go create mode 100644 cli/org/registry/registry_rm.go create mode 100644 cli/org/registry/registry_set.go create mode 100644 cli/repo/registry/registry.go rename cli/{ => repo}/registry/registry_add.go (81%) rename cli/{ => repo}/registry/registry_info.go (84%) rename cli/{ => repo}/registry/registry_list.go (87%) rename cli/{ => repo}/registry/registry_rm.go (84%) rename cli/{ => repo}/registry/registry_set.go (85%) create mode 100644 server/api/global_registry.go create mode 100644 server/api/org_registry.go create mode 100644 server/store/datastore/migration/032_registries_add_user.go create mode 100644 web/src/components/admin/settings/AdminRegistriesTab.vue create mode 100644 web/src/components/org/settings/OrgRegistriesTab.vue create mode 100644 web/src/components/registry/RegistryEdit.vue create mode 100644 web/src/components/registry/RegistryList.vue create mode 100644 woodpecker-go/woodpecker/global_registry.go diff --git a/cli/admin/admin.go b/cli/admin/admin.go new file mode 100644 index 000000000..39187c2c9 --- /dev/null +++ b/cli/admin/admin.go @@ -0,0 +1,30 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package admin + +import ( + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/admin/registry" +) + +// Command exports the admin command set. +var Command = &cli.Command{ + Name: "admin", + Usage: "administer server settings", + Subcommands: []*cli.Command{ + registry.Command, + }, +} diff --git a/cli/registry/registry.go b/cli/admin/registry/registry.go similarity index 92% rename from cli/registry/registry.go rename to cli/admin/registry/registry.go index eb8a7d796..4f091d0e3 100644 --- a/cli/registry/registry.go +++ b/cli/admin/registry/registry.go @@ -1,4 +1,4 @@ -// Copyright 2023 Woodpecker Authors +// Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -21,7 +21,7 @@ import ( // Command exports the registry command set. var Command = &cli.Command{ Name: "registry", - Usage: "manage registries", + Usage: "manage global registries", Subcommands: []*cli.Command{ registryCreateCmd, registryDeleteCmd, diff --git a/cli/admin/registry/registry_add.go b/cli/admin/registry/registry_add.go new file mode 100644 index 000000000..9f779ad8f --- /dev/null +++ b/cli/admin/registry/registry_add.go @@ -0,0 +1,75 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "os" + "strings" + + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/internal" + "go.woodpecker-ci.org/woodpecker/v2/woodpecker-go/woodpecker" +) + +var registryCreateCmd = &cli.Command{ + Name: "add", + Usage: "adds a registry", + Action: registryCreate, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "hostname", + Usage: "registry hostname", + Value: "docker.io", + }, + &cli.StringFlag{ + Name: "username", + Usage: "registry username", + }, + &cli.StringFlag{ + Name: "password", + Usage: "registry password", + }, + }, +} + +func registryCreate(c *cli.Context) error { + var ( + hostname = c.String("hostname") + username = c.String("username") + password = c.String("password") + ) + + client, err := internal.NewClient(c) + if err != nil { + return err + } + registry := &woodpecker.Registry{ + Address: hostname, + Username: username, + Password: password, + } + if strings.HasPrefix(registry.Password, "@") { + path := strings.TrimPrefix(registry.Password, "@") + out, err := os.ReadFile(path) + if err != nil { + return err + } + registry.Password = string(out) + } + + _, err = client.GlobalRegistryCreate(registry) + return err +} diff --git a/cli/admin/registry/registry_info.go b/cli/admin/registry/registry_info.go new file mode 100644 index 000000000..770a7074e --- /dev/null +++ b/cli/admin/registry/registry_info.go @@ -0,0 +1,62 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "html/template" + "os" + + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/common" + "go.woodpecker-ci.org/woodpecker/v2/cli/internal" +) + +var registryInfoCmd = &cli.Command{ + Name: "info", + Usage: "display registry info", + Action: registryInfo, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "hostname", + Usage: "registry hostname", + Value: "docker.io", + }, + common.FormatFlag(tmplRegistryList, true), + }, +} + +func registryInfo(c *cli.Context) error { + var ( + hostname = c.String("hostname") + format = c.String("format") + "\n" + ) + + client, err := internal.NewClient(c) + if err != nil { + return err + } + + registry, err := client.GlobalRegistry(hostname) + if err != nil { + return err + } + + tmpl, err := template.New("_").Parse(format) + if err != nil { + return err + } + return tmpl.Execute(os.Stdout, registry) +} diff --git a/cli/admin/registry/registry_list.go b/cli/admin/registry/registry_list.go new file mode 100644 index 000000000..f40f15ed8 --- /dev/null +++ b/cli/admin/registry/registry_list.go @@ -0,0 +1,65 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "html/template" + "os" + + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/common" + "go.woodpecker-ci.org/woodpecker/v2/cli/internal" +) + +var registryListCmd = &cli.Command{ + Name: "ls", + Usage: "list registries", + Action: registryList, + Flags: []cli.Flag{ + common.FormatFlag(tmplRegistryList, true), + }, +} + +func registryList(c *cli.Context) error { + format := c.String("format") + "\n" + + client, err := internal.NewClient(c) + if err != nil { + return err + } + + list, err := client.GlobalRegistryList() + if err != nil { + return err + } + + tmpl, err := template.New("_").Parse(format) + if err != nil { + return err + } + for _, registry := range list { + if err := tmpl.Execute(os.Stdout, registry); err != nil { + return err + } + } + return nil +} + +// Template for registry list information. +var tmplRegistryList = "\x1b[33m{{ .Address }} \x1b[0m" + ` +Username: {{ .Username }} +Email: {{ .Email }} +` diff --git a/cli/admin/registry/registry_rm.go b/cli/admin/registry/registry_rm.go new file mode 100644 index 000000000..752696aff --- /dev/null +++ b/cli/admin/registry/registry_rm.go @@ -0,0 +1,45 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/internal" +) + +var registryDeleteCmd = &cli.Command{ + Name: "rm", + Usage: "remove a registry", + Action: registryDelete, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "hostname", + Usage: "registry hostname", + Value: "docker.io", + }, + }, +} + +func registryDelete(c *cli.Context) error { + hostname := c.String("hostname") + + client, err := internal.NewClient(c) + if err != nil { + return err + } + + return client.GlobalRegistryDelete(hostname) +} diff --git a/cli/admin/registry/registry_set.go b/cli/admin/registry/registry_set.go new file mode 100644 index 000000000..26daa140a --- /dev/null +++ b/cli/admin/registry/registry_set.go @@ -0,0 +1,78 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "os" + "strings" + + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/common" + "go.woodpecker-ci.org/woodpecker/v2/cli/internal" + "go.woodpecker-ci.org/woodpecker/v2/woodpecker-go/woodpecker" +) + +var registryUpdateCmd = &cli.Command{ + Name: "update", + Usage: "update a registry", + Action: registryUpdate, + Flags: []cli.Flag{ + common.OrgFlag, + &cli.StringFlag{ + Name: "hostname", + Usage: "registry hostname", + Value: "docker.io", + }, + &cli.StringFlag{ + Name: "username", + Usage: "registry username", + }, + &cli.StringFlag{ + Name: "password", + Usage: "registry password", + }, + }, +} + +func registryUpdate(c *cli.Context) error { + var ( + hostname = c.String("hostname") + username = c.String("username") + password = c.String("password") + ) + + client, err := internal.NewClient(c) + if err != nil { + return err + } + + registry := &woodpecker.Registry{ + Address: hostname, + Username: username, + Password: password, + } + if strings.HasPrefix(registry.Password, "@") { + path := strings.TrimPrefix(registry.Password, "@") + out, err := os.ReadFile(path) + if err != nil { + return err + } + registry.Password = string(out) + } + + _, err = client.GlobalRegistryUpdate(registry) + return err +} diff --git a/cli/org/org.go b/cli/org/org.go new file mode 100644 index 000000000..862d5eba8 --- /dev/null +++ b/cli/org/org.go @@ -0,0 +1,30 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org + +import ( + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/org/registry" +) + +// Command exports the org command set. +var Command = &cli.Command{ + Name: "org", + Usage: "manage organizations", + Subcommands: []*cli.Command{ + registry.Command, + }, +} diff --git a/cli/org/registry/registry.go b/cli/org/registry/registry.go new file mode 100644 index 000000000..6d8904958 --- /dev/null +++ b/cli/org/registry/registry.go @@ -0,0 +1,60 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "strconv" + + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/woodpecker-go/woodpecker" +) + +// Command exports the registry command set. +var Command = &cli.Command{ + Name: "registry", + Usage: "manage organization registries", + Subcommands: []*cli.Command{ + registryCreateCmd, + registryDeleteCmd, + registryUpdateCmd, + registryInfoCmd, + registryListCmd, + }, +} + +func parseTargetArgs(client woodpecker.Client, c *cli.Context) (orgID int64, err error) { + orgIDOrName := c.String("organization") + if orgIDOrName == "" { + orgIDOrName = c.Args().First() + } + + if orgIDOrName == "" { + if err := cli.ShowSubcommandHelp(c); err != nil { + return -1, err + } + } + + if orgID, err := strconv.ParseInt(orgIDOrName, 10, 64); err == nil { + return orgID, nil + } + + org, err := client.OrgLookup(orgIDOrName) + if err != nil { + return -1, err + } + + return org.ID, nil +} diff --git a/cli/org/registry/registry_add.go b/cli/org/registry/registry_add.go new file mode 100644 index 000000000..f3cdf5f5f --- /dev/null +++ b/cli/org/registry/registry_add.go @@ -0,0 +1,83 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "os" + "strings" + + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/common" + "go.woodpecker-ci.org/woodpecker/v2/cli/internal" + "go.woodpecker-ci.org/woodpecker/v2/woodpecker-go/woodpecker" +) + +var registryCreateCmd = &cli.Command{ + Name: "add", + Usage: "adds a registry", + ArgsUsage: "[org-id|org-full-name]", + Action: registryCreate, + Flags: []cli.Flag{ + common.OrgFlag, + &cli.StringFlag{ + Name: "hostname", + Usage: "registry hostname", + Value: "docker.io", + }, + &cli.StringFlag{ + Name: "username", + Usage: "registry username", + }, + &cli.StringFlag{ + Name: "password", + Usage: "registry password", + }, + }, +} + +func registryCreate(c *cli.Context) error { + var ( + hostname = c.String("hostname") + username = c.String("username") + password = c.String("password") + ) + + client, err := internal.NewClient(c) + if err != nil { + return err + } + registry := &woodpecker.Registry{ + Address: hostname, + Username: username, + Password: password, + } + if strings.HasPrefix(registry.Password, "@") { + path := strings.TrimPrefix(registry.Password, "@") + out, err := os.ReadFile(path) + if err != nil { + return err + } + registry.Password = string(out) + } + + orgID, err := parseTargetArgs(client, c) + if err != nil { + return err + } + + _, err = client.OrgRegistryCreate(orgID, registry) + return err +} diff --git a/cli/org/registry/registry_info.go b/cli/org/registry/registry_info.go new file mode 100644 index 000000000..a28a4fdd6 --- /dev/null +++ b/cli/org/registry/registry_info.go @@ -0,0 +1,69 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "html/template" + "os" + + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/common" + "go.woodpecker-ci.org/woodpecker/v2/cli/internal" +) + +var registryInfoCmd = &cli.Command{ + Name: "info", + Usage: "display registry info", + ArgsUsage: "[org-id|org-full-name]", + Action: registryInfo, + Flags: []cli.Flag{ + common.OrgFlag, + &cli.StringFlag{ + Name: "hostname", + Usage: "registry hostname", + Value: "docker.io", + }, + common.FormatFlag(tmplRegistryList, true), + }, +} + +func registryInfo(c *cli.Context) error { + var ( + hostname = c.String("hostname") + format = c.String("format") + "\n" + ) + + client, err := internal.NewClient(c) + if err != nil { + return err + } + + orgID, err := parseTargetArgs(client, c) + if err != nil { + return err + } + + registry, err := client.OrgRegistry(orgID, hostname) + if err != nil { + return err + } + + tmpl, err := template.New("_").Parse(format) + if err != nil { + return err + } + return tmpl.Execute(os.Stdout, registry) +} diff --git a/cli/org/registry/registry_list.go b/cli/org/registry/registry_list.go new file mode 100644 index 000000000..4fd1134ae --- /dev/null +++ b/cli/org/registry/registry_list.go @@ -0,0 +1,72 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "html/template" + "os" + + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/common" + "go.woodpecker-ci.org/woodpecker/v2/cli/internal" +) + +var registryListCmd = &cli.Command{ + Name: "ls", + Usage: "list registries", + ArgsUsage: "[org-id|org-full-name]", + Action: registryList, + Flags: []cli.Flag{ + common.OrgFlag, + common.FormatFlag(tmplRegistryList, true), + }, +} + +func registryList(c *cli.Context) error { + format := c.String("format") + "\n" + + client, err := internal.NewClient(c) + if err != nil { + return err + } + + orgID, err := parseTargetArgs(client, c) + if err != nil { + return err + } + + list, err := client.OrgRegistryList(orgID) + if err != nil { + return err + } + + tmpl, err := template.New("_").Parse(format) + if err != nil { + return err + } + for _, registry := range list { + if err := tmpl.Execute(os.Stdout, registry); err != nil { + return err + } + } + return nil +} + +// Template for registry list information. +var tmplRegistryList = "\x1b[33m{{ .Address }} \x1b[0m" + ` +Username: {{ .Username }} +Email: {{ .Email }} +` diff --git a/cli/org/registry/registry_rm.go b/cli/org/registry/registry_rm.go new file mode 100644 index 000000000..e56322c95 --- /dev/null +++ b/cli/org/registry/registry_rm.go @@ -0,0 +1,53 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/common" + "go.woodpecker-ci.org/woodpecker/v2/cli/internal" +) + +var registryDeleteCmd = &cli.Command{ + Name: "rm", + Usage: "remove a registry", + ArgsUsage: "[org-id|org-full-name]", + Action: registryDelete, + Flags: []cli.Flag{ + common.OrgFlag, + &cli.StringFlag{ + Name: "hostname", + Usage: "registry hostname", + Value: "docker.io", + }, + }, +} + +func registryDelete(c *cli.Context) error { + hostname := c.String("hostname") + + client, err := internal.NewClient(c) + if err != nil { + return err + } + + orgID, err := parseTargetArgs(client, c) + if err != nil { + return err + } + + return client.OrgRegistryDelete(orgID, hostname) +} diff --git a/cli/org/registry/registry_set.go b/cli/org/registry/registry_set.go new file mode 100644 index 000000000..a79a0331b --- /dev/null +++ b/cli/org/registry/registry_set.go @@ -0,0 +1,84 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "os" + "strings" + + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/common" + "go.woodpecker-ci.org/woodpecker/v2/cli/internal" + "go.woodpecker-ci.org/woodpecker/v2/woodpecker-go/woodpecker" +) + +var registryUpdateCmd = &cli.Command{ + Name: "update", + Usage: "update a registry", + ArgsUsage: "[org-id|org-full-name]", + Action: registryUpdate, + Flags: []cli.Flag{ + common.OrgFlag, + &cli.StringFlag{ + Name: "hostname", + Usage: "registry hostname", + Value: "docker.io", + }, + &cli.StringFlag{ + Name: "username", + Usage: "registry username", + }, + &cli.StringFlag{ + Name: "password", + Usage: "registry password", + }, + }, +} + +func registryUpdate(c *cli.Context) error { + var ( + hostname = c.String("hostname") + username = c.String("username") + password = c.String("password") + ) + + client, err := internal.NewClient(c) + if err != nil { + return err + } + + registry := &woodpecker.Registry{ + Address: hostname, + Username: username, + Password: password, + } + if strings.HasPrefix(registry.Password, "@") { + path := strings.TrimPrefix(registry.Password, "@") + out, err := os.ReadFile(path) + if err != nil { + return err + } + registry.Password = string(out) + } + + orgID, err := parseTargetArgs(client, c) + if err != nil { + return err + } + + _, err = client.OrgRegistryUpdate(orgID, registry) + return err +} diff --git a/cli/repo/registry/registry.go b/cli/repo/registry/registry.go new file mode 100644 index 000000000..29c8d35fa --- /dev/null +++ b/cli/repo/registry/registry.go @@ -0,0 +1,44 @@ +// Copyright 2023 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/internal" + "go.woodpecker-ci.org/woodpecker/v2/woodpecker-go/woodpecker" +) + +// Command exports the registry command set. +var Command = &cli.Command{ + Name: "registry", + Usage: "manage registries", + Subcommands: []*cli.Command{ + registryCreateCmd, + registryDeleteCmd, + registryUpdateCmd, + registryInfoCmd, + registryListCmd, + }, +} + +func parseTargetArgs(client woodpecker.Client, c *cli.Context) (repoID int64, err error) { + repoIDOrFullName := c.String("repository") + if repoIDOrFullName == "" { + repoIDOrFullName = c.Args().First() + } + + return internal.ParseRepo(client, repoIDOrFullName) +} diff --git a/cli/registry/registry_add.go b/cli/repo/registry/registry_add.go similarity index 81% rename from cli/registry/registry_add.go rename to cli/repo/registry/registry_add.go index 13d6ed25c..dae231048 100644 --- a/cli/registry/registry_add.go +++ b/cli/repo/registry/registry_add.go @@ -50,22 +50,15 @@ var registryCreateCmd = &cli.Command{ func registryCreate(c *cli.Context) error { var ( - hostname = c.String("hostname") - username = c.String("username") - password = c.String("password") - repoIDOrFullName = c.String("repository") + hostname = c.String("hostname") + username = c.String("username") + password = c.String("password") ) - if repoIDOrFullName == "" { - repoIDOrFullName = c.Args().First() - } + client, err := internal.NewClient(c) if err != nil { return err } - repoID, err := internal.ParseRepo(client, repoIDOrFullName) - if err != nil { - return err - } registry := &woodpecker.Registry{ Address: hostname, Username: username, @@ -79,8 +72,12 @@ func registryCreate(c *cli.Context) error { } registry.Password = string(out) } - if _, err := client.RegistryCreate(repoID, registry); err != nil { + + repoID, err := parseTargetArgs(client, c) + if err != nil { return err } - return nil + + _, err = client.RegistryCreate(repoID, registry) + return err } diff --git a/cli/registry/registry_info.go b/cli/repo/registry/registry_info.go similarity index 84% rename from cli/registry/registry_info.go rename to cli/repo/registry/registry_info.go index 8bbfceeba..1f984c415 100644 --- a/cli/registry/registry_info.go +++ b/cli/repo/registry/registry_info.go @@ -42,25 +42,25 @@ var registryInfoCmd = &cli.Command{ func registryInfo(c *cli.Context) error { var ( - hostname = c.String("hostname") - repoIDOrFullName = c.String("repository") - format = c.String("format") + "\n" + hostname = c.String("hostname") + format = c.String("format") + "\n" ) - if repoIDOrFullName == "" { - repoIDOrFullName = c.Args().First() - } + client, err := internal.NewClient(c) if err != nil { return err } - repoID, err := internal.ParseRepo(client, repoIDOrFullName) + + repoID, err := parseTargetArgs(client, c) if err != nil { return err } + registry, err := client.Registry(repoID, hostname) if err != nil { return err } + tmpl, err := template.New("_").Parse(format) if err != nil { return err diff --git a/cli/registry/registry_list.go b/cli/repo/registry/registry_list.go similarity index 87% rename from cli/registry/registry_list.go rename to cli/repo/registry/registry_list.go index 99221903d..cd0a89a6b 100644 --- a/cli/registry/registry_list.go +++ b/cli/repo/registry/registry_list.go @@ -36,25 +36,23 @@ var registryListCmd = &cli.Command{ } func registryList(c *cli.Context) error { - var ( - format = c.String("format") + "\n" - repoIDOrFullName = c.String("repository") - ) - if repoIDOrFullName == "" { - repoIDOrFullName = c.Args().First() - } + format := c.String("format") + "\n" + client, err := internal.NewClient(c) if err != nil { return err } - repoID, err := internal.ParseRepo(client, repoIDOrFullName) + + repoID, err := parseTargetArgs(client, c) if err != nil { return err } + list, err := client.RegistryList(repoID) if err != nil { return err } + tmpl, err := template.New("_").Parse(format) if err != nil { return err diff --git a/cli/registry/registry_rm.go b/cli/repo/registry/registry_rm.go similarity index 84% rename from cli/registry/registry_rm.go rename to cli/repo/registry/registry_rm.go index 108c7b772..d1b058a17 100644 --- a/cli/registry/registry_rm.go +++ b/cli/repo/registry/registry_rm.go @@ -37,20 +37,17 @@ var registryDeleteCmd = &cli.Command{ } func registryDelete(c *cli.Context) error { - var ( - hostname = c.String("hostname") - repoIDOrFullName = c.String("repository") - ) - if repoIDOrFullName == "" { - repoIDOrFullName = c.Args().First() - } + hostname := c.String("hostname") + client, err := internal.NewClient(c) if err != nil { return err } - repoID, err := internal.ParseRepo(client, repoIDOrFullName) + + repoID, err := parseTargetArgs(client, c) if err != nil { return err } + return client.RegistryDelete(repoID, hostname) } diff --git a/cli/registry/registry_set.go b/cli/repo/registry/registry_set.go similarity index 85% rename from cli/registry/registry_set.go rename to cli/repo/registry/registry_set.go index 48916ddad..5b6497f49 100644 --- a/cli/registry/registry_set.go +++ b/cli/repo/registry/registry_set.go @@ -50,22 +50,16 @@ var registryUpdateCmd = &cli.Command{ func registryUpdate(c *cli.Context) error { var ( - hostname = c.String("hostname") - username = c.String("username") - password = c.String("password") - repoIDOrFullName = c.String("repository") + hostname = c.String("hostname") + username = c.String("username") + password = c.String("password") ) - if repoIDOrFullName == "" { - repoIDOrFullName = c.Args().First() - } + client, err := internal.NewClient(c) if err != nil { return err } - repoID, err := internal.ParseRepo(client, repoIDOrFullName) - if err != nil { - return err - } + registry := &woodpecker.Registry{ Address: hostname, Username: username, @@ -79,6 +73,12 @@ func registryUpdate(c *cli.Context) error { } registry.Password = string(out) } + + repoID, err := parseTargetArgs(client, c) + if err != nil { + return err + } + _, err = client.RegistryUpdate(repoID, registry) return err } diff --git a/cli/repo/repo.go b/cli/repo/repo.go index 43e8d5338..ea42c5616 100644 --- a/cli/repo/repo.go +++ b/cli/repo/repo.go @@ -16,6 +16,8 @@ package repo import ( "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/repo/registry" ) // Command exports the repository command. @@ -31,5 +33,6 @@ var Command = &cli.Command{ repoRepairCmd, repoChownCmd, repoSyncCmd, + registry.Command, }, } diff --git a/cmd/cli/app.go b/cmd/cli/app.go index db6edca17..8b78b3ecd 100644 --- a/cmd/cli/app.go +++ b/cmd/cli/app.go @@ -17,6 +17,7 @@ package main import ( "github.com/urfave/cli/v2" + "go.woodpecker-ci.org/woodpecker/v2/cli/admin" "go.woodpecker-ci.org/woodpecker/v2/cli/common" "go.woodpecker-ci.org/woodpecker/v2/cli/cron" "go.woodpecker-ci.org/woodpecker/v2/cli/deploy" @@ -25,9 +26,10 @@ import ( "go.woodpecker-ci.org/woodpecker/v2/cli/lint" "go.woodpecker-ci.org/woodpecker/v2/cli/log" "go.woodpecker-ci.org/woodpecker/v2/cli/loglevel" + "go.woodpecker-ci.org/woodpecker/v2/cli/org" "go.woodpecker-ci.org/woodpecker/v2/cli/pipeline" - "go.woodpecker-ci.org/woodpecker/v2/cli/registry" "go.woodpecker-ci.org/woodpecker/v2/cli/repo" + "go.woodpecker-ci.org/woodpecker/v2/cli/repo/registry" "go.woodpecker-ci.org/woodpecker/v2/cli/secret" "go.woodpecker-ci.org/woodpecker/v2/cli/setup" "go.woodpecker-ci.org/woodpecker/v2/cli/update" @@ -47,14 +49,17 @@ func newApp() *cli.App { app.After = common.After app.Suggest = true app.Commands = []*cli.Command{ + admin.Command, + org.Command, + repo.Command, pipeline.Command, log.Command, deploy.Command, exec.Command, info.Command, + // TODO: Remove in 3.x registry.Command, secret.Command, - repo.Command, user.Command, lint.Command, loglevel.Command, diff --git a/cmd/server/docs/docs.go b/cmd/server/docs/docs.go index e7be9fe25..665931cf1 100644 --- a/cmd/server/docs/docs.go +++ b/cmd/server/docs/docs.go @@ -1123,6 +1123,233 @@ const docTemplate = `{ } } }, + "/orgs/{org_id}/registries": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Organization registries" + ], + "summary": "List organization registries", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "the org's id", + "name": "org_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 1, + "description": "for response pagination, page offset number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 50, + "description": "for response pagination, max items per page", + "name": "perPage", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Registry" + } + } + } + } + }, + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Organization registries" + ], + "summary": "Create an organization registry", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "the org's id", + "name": "org_id", + "in": "path", + "required": true + }, + { + "description": "the new registry", + "name": "registryData", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Registry" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Registry" + } + } + } + } + }, + "/orgs/{org_id}/registries/{registry}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Organization registries" + ], + "summary": "Get a organization registry by address", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "the org's id", + "name": "org_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "the registry's address", + "name": "registry", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Registry" + } + } + } + }, + "delete": { + "produces": [ + "text/plain" + ], + "tags": [ + "Organization registries" + ], + "summary": "Delete an organization registry by name", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "the org's id", + "name": "org_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "the registry's name", + "name": "registry", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + }, + "patch": { + "produces": [ + "application/json" + ], + "tags": [ + "Organization registries" + ], + "summary": "Update an organization registry by name", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "the org's id", + "name": "org_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "the registry's name", + "name": "registry", + "in": "path", + "required": true + }, + { + "description": "the update registry data", + "name": "registryData", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Registry" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Registry" + } + } + } + } + }, "/orgs/{org_id}/secrets": { "get": { "produces": [ @@ -1493,6 +1720,198 @@ const docTemplate = `{ } } }, + "/registries": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Registries" + ], + "summary": "List global registries", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "integer", + "default": 1, + "description": "for response pagination, page offset number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 50, + "description": "for response pagination, max items per page", + "name": "perPage", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Registry" + } + } + } + } + }, + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Registries" + ], + "summary": "Create a global registry", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "the registry object data", + "name": "registry", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Registry" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Registry" + } + } + } + } + }, + "/registries/{registry}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Registries" + ], + "summary": "Get a global registry by name", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "the registry's name", + "name": "registry", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Registry" + } + } + } + }, + "delete": { + "produces": [ + "text/plain" + ], + "tags": [ + "Registries" + ], + "summary": "Delete a global registry by name", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "the registry's name", + "name": "registry", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + }, + "patch": { + "produces": [ + "application/json" + ], + "tags": [ + "Registries" + ], + "summary": "Update a global registry by name", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "the registry's name", + "name": "registry", + "in": "path", + "required": true + }, + { + "description": "the registry's data", + "name": "registryData", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Registry" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Registry" + } + } + } + } + }, "/repos": { "get": { "description": "Returns a list of all repositories. Requires admin rights.", @@ -2799,7 +3218,7 @@ const docTemplate = `{ } } }, - "/repos/{repo_id}/registry": { + "/repos/{repo_id}/registries": { "get": { "produces": [ "application/json" @@ -2895,7 +3314,7 @@ const docTemplate = `{ } } }, - "/repos/{repo_id}/registry/{registry}": { + "/repos/{repo_id}/registries/{registry}": { "get": { "produces": [ "application/json" @@ -4376,9 +4795,18 @@ const docTemplate = `{ "id": { "type": "integer" }, + "org_id": { + "type": "integer" + }, "password": { "type": "string" }, + "readonly": { + "type": "boolean" + }, + "repo_id": { + "type": "integer" + }, "username": { "type": "string" } diff --git a/pipeline/rpc/proto/woodpecker.pb.go b/pipeline/rpc/proto/woodpecker.pb.go index f66e9be5b..383314408 100644 --- a/pipeline/rpc/proto/woodpecker.pb.go +++ b/pipeline/rpc/proto/woodpecker.pb.go @@ -15,7 +15,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.34.1 +// protoc-gen-go v1.34.2 // protoc v4.25.1 // source: woodpecker.proto @@ -1312,7 +1312,7 @@ func file_woodpecker_proto_rawDescGZIP() []byte { } var file_woodpecker_proto_msgTypes = make([]protoimpl.MessageInfo, 21) -var file_woodpecker_proto_goTypes = []interface{}{ +var file_woodpecker_proto_goTypes = []any{ (*StepState)(nil), // 0: proto.StepState (*WorkflowState)(nil), // 1: proto.WorkflowState (*LogEntry)(nil), // 2: proto.LogEntry @@ -1380,7 +1380,7 @@ func file_woodpecker_proto_init() { return } if !protoimpl.UnsafeEnabled { - file_woodpecker_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[0].Exporter = func(v any, i int) any { switch v := v.(*StepState); i { case 0: return &v.state @@ -1392,7 +1392,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[1].Exporter = func(v any, i int) any { switch v := v.(*WorkflowState); i { case 0: return &v.state @@ -1404,7 +1404,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[2].Exporter = func(v any, i int) any { switch v := v.(*LogEntry); i { case 0: return &v.state @@ -1416,7 +1416,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[3].Exporter = func(v any, i int) any { switch v := v.(*Filter); i { case 0: return &v.state @@ -1428,7 +1428,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[4].Exporter = func(v any, i int) any { switch v := v.(*Workflow); i { case 0: return &v.state @@ -1440,7 +1440,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[5].Exporter = func(v any, i int) any { switch v := v.(*NextRequest); i { case 0: return &v.state @@ -1452,7 +1452,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[6].Exporter = func(v any, i int) any { switch v := v.(*InitRequest); i { case 0: return &v.state @@ -1464,7 +1464,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[7].Exporter = func(v any, i int) any { switch v := v.(*WaitRequest); i { case 0: return &v.state @@ -1476,7 +1476,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[8].Exporter = func(v any, i int) any { switch v := v.(*DoneRequest); i { case 0: return &v.state @@ -1488,7 +1488,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[9].Exporter = func(v any, i int) any { switch v := v.(*ExtendRequest); i { case 0: return &v.state @@ -1500,7 +1500,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[10].Exporter = func(v any, i int) any { switch v := v.(*UpdateRequest); i { case 0: return &v.state @@ -1512,7 +1512,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[11].Exporter = func(v any, i int) any { switch v := v.(*LogRequest); i { case 0: return &v.state @@ -1524,7 +1524,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[12].Exporter = func(v any, i int) any { switch v := v.(*Empty); i { case 0: return &v.state @@ -1536,7 +1536,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[13].Exporter = func(v any, i int) any { switch v := v.(*ReportHealthRequest); i { case 0: return &v.state @@ -1548,7 +1548,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[14].Exporter = func(v any, i int) any { switch v := v.(*RegisterAgentRequest); i { case 0: return &v.state @@ -1560,7 +1560,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[15].Exporter = func(v any, i int) any { switch v := v.(*VersionResponse); i { case 0: return &v.state @@ -1572,7 +1572,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[16].Exporter = func(v any, i int) any { switch v := v.(*NextResponse); i { case 0: return &v.state @@ -1584,7 +1584,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[17].Exporter = func(v any, i int) any { switch v := v.(*RegisterAgentResponse); i { case 0: return &v.state @@ -1596,7 +1596,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[18].Exporter = func(v any, i int) any { switch v := v.(*AuthRequest); i { case 0: return &v.state @@ -1608,7 +1608,7 @@ func file_woodpecker_proto_init() { return nil } } - file_woodpecker_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + file_woodpecker_proto_msgTypes[19].Exporter = func(v any, i int) any { switch v := v.(*AuthResponse); i { case 0: return &v.state diff --git a/server/api/global_registry.go b/server/api/global_registry.go new file mode 100644 index 000000000..a9ffd9a89 --- /dev/null +++ b/server/api/global_registry.go @@ -0,0 +1,170 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "go.woodpecker-ci.org/woodpecker/v2/server" + "go.woodpecker-ci.org/woodpecker/v2/server/model" + "go.woodpecker-ci.org/woodpecker/v2/server/router/middleware/session" +) + +// GetGlobalRegistryList +// +// @Summary List global registries +// @Router /registries [get] +// @Produce json +// @Success 200 {array} Registry +// @Tags Registries +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param page query int false "for response pagination, page offset number" default(1) +// @Param perPage query int false "for response pagination, max items per page" default(50) +func GetGlobalRegistryList(c *gin.Context) { + registryService := server.Config.Services.Manager.RegistryService() + list, err := registryService.GlobalRegistryList(session.Pagination(c)) + if err != nil { + c.String(http.StatusInternalServerError, "Error getting global registry list. %s", err) + return + } + // copy the registry detail to remove the sensitive + // password and token fields. + for i, registry := range list { + list[i] = registry.Copy() + } + c.JSON(http.StatusOK, list) +} + +// GetGlobalRegistry +// +// @Summary Get a global registry by name +// @Router /registries/{registry} [get] +// @Produce json +// @Success 200 {object} Registry +// @Tags Registries +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param registry path string true "the registry's name" +func GetGlobalRegistry(c *gin.Context) { + addr := c.Param("registry") + registryService := server.Config.Services.Manager.RegistryService() + registry, err := registryService.GlobalRegistryFind(addr) + if err != nil { + handleDBError(c, err) + return + } + c.JSON(http.StatusOK, registry.Copy()) +} + +// PostGlobalRegistry +// +// @Summary Create a global registry +// @Router /registries [post] +// @Produce json +// @Success 200 {object} Registry +// @Tags Registries +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param registry body Registry true "the registry object data" +func PostGlobalRegistry(c *gin.Context) { + in := new(model.Registry) + if err := c.Bind(in); err != nil { + c.String(http.StatusBadRequest, "Error parsing global registry. %s", err) + return + } + registry := &model.Registry{ + Address: in.Address, + Username: in.Username, + Password: in.Password, + } + if err := registry.Validate(); err != nil { + c.String(http.StatusBadRequest, "Error inserting global registry. %s", err) + return + } + + registryService := server.Config.Services.Manager.RegistryService() + if err := registryService.GlobalRegistryCreate(registry); err != nil { + c.String(http.StatusInternalServerError, "Error inserting global registry %q. %s", in.Address, err) + return + } + c.JSON(http.StatusOK, registry.Copy()) +} + +// PatchGlobalRegistry +// +// @Summary Update a global registry by name +// @Router /registries/{registry} [patch] +// @Produce json +// @Success 200 {object} Registry +// @Tags Registries +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param registry path string true "the registry's name" +// @Param registryData body Registry true "the registry's data" +func PatchGlobalRegistry(c *gin.Context) { + addr := c.Param("registry") + + in := new(model.Registry) + err := c.Bind(in) + if err != nil { + c.String(http.StatusBadRequest, "Error parsing registry. %s", err) + return + } + + registryService := server.Config.Services.Manager.RegistryService() + registry, err := registryService.GlobalRegistryFind(addr) + if err != nil { + handleDBError(c, err) + return + } + if in.Address != "" { + registry.Address = in.Address + } + if in.Username != "" { + registry.Username = in.Username + } + if in.Password != "" { + registry.Password = in.Password + } + + if err := registry.Validate(); err != nil { + c.String(http.StatusBadRequest, "Error updating global registry. %s", err) + return + } + + if err := registryService.GlobalRegistryUpdate(registry); err != nil { + c.String(http.StatusInternalServerError, "Error updating global registry %q. %s", in.Address, err) + return + } + c.JSON(http.StatusOK, registry.Copy()) +} + +// DeleteGlobalRegistry +// +// @Summary Delete a global registry by name +// @Router /registries/{registry} [delete] +// @Produce plain +// @Success 204 +// @Tags Registries +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param registry path string true "the registry's name" +func DeleteGlobalRegistry(c *gin.Context) { + addr := c.Param("registry") + registryService := server.Config.Services.Manager.RegistryService() + if err := registryService.GlobalRegistryDelete(addr); err != nil { + handleDBError(c, err) + return + } + c.Status(http.StatusNoContent) +} diff --git a/server/api/org_registry.go b/server/api/org_registry.go new file mode 100644 index 000000000..b52782675 --- /dev/null +++ b/server/api/org_registry.go @@ -0,0 +1,207 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "go.woodpecker-ci.org/woodpecker/v2/server" + "go.woodpecker-ci.org/woodpecker/v2/server/model" + "go.woodpecker-ci.org/woodpecker/v2/server/router/middleware/session" +) + +// GetOrgRegistry +// +// @Summary Get a organization registry by address +// @Router /orgs/{org_id}/registries/{registry} [get] +// @Produce json +// @Success 200 {object} Registry +// @Tags Organization registries +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param org_id path string true "the org's id" +// @Param registry path string true "the registry's address" +func GetOrgRegistry(c *gin.Context) { + addr := c.Param("registry") + + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Error parsing org id. %s", err) + return + } + + registryService := server.Config.Services.Manager.RegistryService() + registry, err := registryService.OrgRegistryFind(orgID, addr) + if err != nil { + handleDBError(c, err) + return + } + c.JSON(http.StatusOK, registry.Copy()) +} + +// GetOrgRegistryList +// +// @Summary List organization registries +// @Router /orgs/{org_id}/registries [get] +// @Produce json +// @Success 200 {array} Registry +// @Tags Organization registries +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param org_id path string true "the org's id" +// @Param page query int false "for response pagination, page offset number" default(1) +// @Param perPage query int false "for response pagination, max items per page" default(50) +func GetOrgRegistryList(c *gin.Context) { + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Error parsing org id. %s", err) + return + } + + registryService := server.Config.Services.Manager.RegistryService() + list, err := registryService.OrgRegistryList(orgID, session.Pagination(c)) + if err != nil { + c.String(http.StatusInternalServerError, "Error getting registry list for %q. %s", orgID, err) + return + } + // copy the registry detail to remove the sensitive + // password and token fields. + for i, registry := range list { + list[i] = registry.Copy() + } + c.JSON(http.StatusOK, list) +} + +// PostOrgRegistry +// +// @Summary Create an organization registry +// @Router /orgs/{org_id}/registries [post] +// @Produce json +// @Success 200 {object} Registry +// @Tags Organization registries +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param org_id path string true "the org's id" +// @Param registryData body Registry true "the new registry" +func PostOrgRegistry(c *gin.Context) { + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Error parsing org id. %s", err) + return + } + + in := new(model.Registry) + if err := c.Bind(in); err != nil { + c.String(http.StatusBadRequest, "Error parsing org %q registry. %s", orgID, err) + return + } + registry := &model.Registry{ + OrgID: orgID, + Address: in.Address, + Username: in.Username, + Password: in.Password, + } + if err := registry.Validate(); err != nil { + c.String(http.StatusUnprocessableEntity, "Error inserting org %q registry. %s", orgID, err) + return + } + + registryService := server.Config.Services.Manager.RegistryService() + if err := registryService.OrgRegistryCreate(orgID, registry); err != nil { + c.String(http.StatusInternalServerError, "Error inserting org %q registry %q. %s", orgID, in.Address, err) + return + } + c.JSON(http.StatusOK, registry.Copy()) +} + +// PatchOrgRegistry +// +// @Summary Update an organization registry by name +// @Router /orgs/{org_id}/registries/{registry} [patch] +// @Produce json +// @Success 200 {object} Registry +// @Tags Organization registries +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param org_id path string true "the org's id" +// @Param registry path string true "the registry's name" +// @Param registryData body Registry true "the update registry data" +func PatchOrgRegistry(c *gin.Context) { + addr := c.Param("registry") + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Error parsing org id. %s", err) + return + } + + in := new(model.Registry) + err = c.Bind(in) + if err != nil { + c.String(http.StatusBadRequest, "Error parsing registry. %s", err) + return + } + + registryService := server.Config.Services.Manager.RegistryService() + registry, err := registryService.OrgRegistryFind(orgID, addr) + if err != nil { + handleDBError(c, err) + return + } + if in.Address != "" { + registry.Address = in.Address + } + if in.Username != "" { + registry.Username = in.Username + } + if in.Password != "" { + registry.Password = in.Password + } + + if err := registry.Validate(); err != nil { + c.String(http.StatusUnprocessableEntity, "Error updating org %q registry. %s", orgID, err) + return + } + + if err := registryService.OrgRegistryUpdate(orgID, registry); err != nil { + c.String(http.StatusInternalServerError, "Error updating org %q registry %q. %s", orgID, in.Address, err) + return + } + c.JSON(http.StatusOK, registry.Copy()) +} + +// DeleteOrgRegistry +// +// @Summary Delete an organization registry by name +// @Router /orgs/{org_id}/registries/{registry} [delete] +// @Produce plain +// @Success 204 +// @Tags Organization registries +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param org_id path string true "the org's id" +// @Param registry path string true "the registry's name" +func DeleteOrgRegistry(c *gin.Context) { + addr := c.Param("registry") + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Error parsing org id. %s", err) + return + } + + registryService := server.Config.Services.Manager.RegistryService() + if err := registryService.OrgRegistryDelete(orgID, addr); err != nil { + handleDBError(c, err) + return + } + c.Status(http.StatusNoContent) +} diff --git a/server/api/registry.go b/server/api/registry.go index c26ea42b7..360db1483 100644 --- a/server/api/registry.go +++ b/server/api/registry.go @@ -27,7 +27,7 @@ import ( // GetRegistry // // @Summary Get a registry by name -// @Router /repos/{repo_id}/registry/{registry} [get] +// @Router /repos/{repo_id}/registries/{registry} [get] // @Produce json // @Success 200 {object} Registry // @Tags Repository registries @@ -36,10 +36,10 @@ import ( // @Param registry path string true "the registry name" func GetRegistry(c *gin.Context) { repo := session.Repo(c) - name := c.Param("registry") + addr := c.Param("registry") registryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo) - registry, err := registryService.RegistryFind(repo, name) + registry, err := registryService.RegistryFind(repo, addr) if err != nil { handleDBError(c, err) return @@ -50,7 +50,7 @@ func GetRegistry(c *gin.Context) { // PostRegistry // // @Summary Create a registry -// @Router /repos/{repo_id}/registry [post] +// @Router /repos/{repo_id}/registries [post] // @Produce json // @Success 200 {object} Registry // @Tags Repository registries @@ -87,7 +87,7 @@ func PostRegistry(c *gin.Context) { // PatchRegistry // // @Summary Update a registry by name -// @Router /repos/{repo_id}/registry/{registry} [patch] +// @Router /repos/{repo_id}/registries/{registry} [patch] // @Produce json // @Success 200 {object} Registry // @Tags Repository registries @@ -96,10 +96,8 @@ func PostRegistry(c *gin.Context) { // @Param registry path string true "the registry name" // @Param registryData body Registry true "the attributes for the registry" func PatchRegistry(c *gin.Context) { - var ( - repo = session.Repo(c) - name = c.Param("registry") - ) + repo := session.Repo(c) + addr := c.Param("registry") in := new(model.Registry) err := c.Bind(in) @@ -109,7 +107,7 @@ func PatchRegistry(c *gin.Context) { } registryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo) - registry, err := registryService.RegistryFind(repo, name) + registry, err := registryService.RegistryFind(repo, addr) if err != nil { handleDBError(c, err) return @@ -135,7 +133,7 @@ func PatchRegistry(c *gin.Context) { // GetRegistryList // // @Summary List registries -// @Router /repos/{repo_id}/registry [get] +// @Router /repos/{repo_id}/registries [get] // @Produce json // @Success 200 {array} Registry // @Tags Repository registries @@ -162,7 +160,7 @@ func GetRegistryList(c *gin.Context) { // DeleteRegistry // // @Summary Delete a registry by name -// @Router /repos/{repo_id}/registry/{registry} [delete] +// @Router /repos/{repo_id}/registries/{registry} [delete] // @Produce plain // @Success 204 // @Tags Repository registries @@ -171,10 +169,10 @@ func GetRegistryList(c *gin.Context) { // @Param registry path string true "the registry name" func DeleteRegistry(c *gin.Context) { repo := session.Repo(c) - name := c.Param("registry") + addr := c.Param("registry") registryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo) - err := registryService.RegistryDelete(repo, name) + err := registryService.RegistryDelete(repo, addr) if err != nil { handleDBError(c, err) return diff --git a/server/model/registry.go b/server/model/registry.go index c2df9bac4..20af16fed 100644 --- a/server/model/registry.go +++ b/server/model/registry.go @@ -29,16 +29,33 @@ var ( // Registry represents a docker registry with credentials. type Registry struct { ID int64 `json:"id" xorm:"pk autoincr 'id'"` - RepoID int64 `json:"-" xorm:"UNIQUE(s) INDEX 'repo_id'"` - Address string `json:"address" xorm:"UNIQUE(s) INDEX 'address'"` + OrgID int64 `json:"org_id" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'org_id'"` + RepoID int64 `json:"repo_id" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'repo_id'"` + Address string `json:"address" xorm:"NOT NULL UNIQUE(s) INDEX 'address'"` Username string `json:"username" xorm:"varchar(2000) 'username'"` Password string `json:"password" xorm:"TEXT 'password'"` + ReadOnly bool `json:"readonly" xorm:"-"` } // @name Registry func (r Registry) TableName() string { return "registries" } +// Global registry. +func (r Registry) IsGlobal() bool { + return r.RepoID == 0 && r.OrgID == 0 +} + +// Organization registry. +func (r Registry) IsOrganization() bool { + return r.RepoID == 0 && r.OrgID != 0 +} + +// Repository registry. +func (r Registry) IsRepository() bool { + return r.RepoID != 0 && r.OrgID == 0 +} + // Validate validates the registry information. func (r *Registry) Validate() error { switch { @@ -58,8 +75,10 @@ func (r *Registry) Validate() error { func (r *Registry) Copy() *Registry { return &Registry{ ID: r.ID, + OrgID: r.OrgID, RepoID: r.RepoID, Address: r.Address, Username: r.Username, + ReadOnly: r.ReadOnly, } } diff --git a/server/pipeline/items.go b/server/pipeline/items.go index 92f59af7e..b6b2dc917 100644 --- a/server/pipeline/items.go +++ b/server/pipeline/items.go @@ -44,13 +44,13 @@ func parsePipeline(forge forge.Forge, store store.Store, currentPipeline *model. } secretService := server.Config.Services.Manager.SecretServiceFromRepo(repo) - secs, err := secretService.SecretListPipeline(repo, currentPipeline, &model.ListOptions{All: true}) + secs, err := secretService.SecretListPipeline(repo, currentPipeline) if err != nil { log.Error().Err(err).Msgf("error getting secrets for %s#%d", repo.FullName, currentPipeline.Number) } registryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo) - regs, err := registryService.RegistryList(repo, &model.ListOptions{All: true}) + regs, err := registryService.RegistryListPipeline(repo, currentPipeline) if err != nil { log.Error().Err(err).Msgf("error getting registry credentials for %s#%d", repo.FullName, currentPipeline.Number) } diff --git a/server/router/api.go b/server/router/api.go index b433c7c61..cce04ef0e 100644 --- a/server/router/api.go +++ b/server/router/api.go @@ -61,11 +61,18 @@ func apiRoutes(e *gin.RouterGroup) { org.Use(session.MustOrgMember(true)) org.DELETE("", session.MustAdmin(), api.DeleteOrg) org.GET("", api.GetOrg) + org.GET("/secrets", api.GetOrgSecretList) org.POST("/secrets", api.PostOrgSecret) org.GET("/secrets/:secret", api.GetOrgSecret) org.PATCH("/secrets/:secret", api.PatchOrgSecret) org.DELETE("/secrets/:secret", api.DeleteOrgSecret) + + org.GET("/registries", api.GetOrgRegistryList) + org.POST("/registries", api.PostOrgRegistry) + org.GET("/registries/:registry", api.GetOrgRegistry) + org.PATCH("/registries/:registry", api.PatchOrgRegistry) + org.DELETE("/registries/:registry", api.DeleteOrgRegistry) } } } @@ -118,6 +125,13 @@ func apiRoutes(e *gin.RouterGroup) { repo.DELETE("/secrets/:secret", session.MustPush, api.DeleteSecret) // requires push permissions + repo.GET("/registries", session.MustPush, api.GetRegistryList) + repo.POST("/registries", session.MustPush, api.PostRegistry) + repo.GET("/registries/:registry", session.MustPush, api.GetRegistry) + repo.PATCH("/registries/:registry", session.MustPush, api.PatchRegistry) + repo.DELETE("/registries/:registry", session.MustPush, api.DeleteRegistry) + + // TODO: remove with 3.x repo.GET("/registry", session.MustPush, api.GetRegistryList) repo.POST("/registry", session.MustPush, api.PostRegistry) repo.GET("/registry/:registry", session.MustPush, api.GetRegistry) @@ -184,6 +198,21 @@ func apiRoutes(e *gin.RouterGroup) { secrets.DELETE("/:secret", api.DeleteGlobalSecret) } + // global registries can be read without actual values by any user + readGlobalRegistries := apiBase.Group("/registries") + { + readGlobalRegistries.Use(session.MustUser()) + readGlobalRegistries.GET("", api.GetGlobalRegistryList) + readGlobalRegistries.GET("/:registry", api.GetGlobalRegistry) + } + registries := apiBase.Group("/registries") + { + registries.Use(session.MustAdmin()) + registries.POST("", api.PostGlobalRegistry) + registries.PATCH("/:registry", api.PatchGlobalRegistry) + registries.DELETE("/:registry", api.DeleteGlobalRegistry) + } + logLevel := apiBase.Group("/log-level") { logLevel.Use(session.MustAdmin()) diff --git a/server/services/registry/combined.go b/server/services/registry/combined.go index 4f52bca1a..bdb249bcd 100644 --- a/server/services/registry/combined.go +++ b/server/services/registry/combined.go @@ -15,7 +15,10 @@ package registry import ( + "errors" + "go.woodpecker-ci.org/woodpecker/v2/server/model" + "go.woodpecker-ci.org/woodpecker/v2/server/store/types" ) type combined struct { @@ -31,29 +34,44 @@ func NewCombined(dbRegistry Service, registries ...ReadOnlyService) Service { } } -func (c *combined) RegistryFind(repo *model.Repo, name string) (*model.Registry, error) { - for _, registry := range c.registries { - res, err := registry.RegistryFind(repo, name) - if err != nil { - return nil, err - } - if res != nil { - return res, nil - } - } - return nil, nil +func (c *combined) RegistryFind(repo *model.Repo, addr string) (*model.Registry, error) { + return c.dbRegistry.RegistryFind(repo, addr) } func (c *combined) RegistryList(repo *model.Repo, p *model.ListOptions) ([]*model.Registry, error) { - var registries []*model.Registry + return c.dbRegistry.RegistryList(repo, p) +} + +func (c *combined) RegistryListPipeline(repo *model.Repo, pipeline *model.Pipeline) ([]*model.Registry, error) { + dbRegistries, err := c.dbRegistry.RegistryListPipeline(repo, pipeline) + if err != nil { + return nil, err + } + + registries := make([]*model.Registry, 0, len(dbRegistries)) + exists := make(map[string]struct{}, len(dbRegistries)) + + // Assign database stored registries to the map to avoid duplicates + // from the combined registries so to prioritize ones in database. + for _, reg := range dbRegistries { + exists[reg.Address] = struct{}{} + } + for _, registry := range c.registries { - list, err := registry.RegistryList(repo, &model.ListOptions{All: true}) + list, err := registry.GlobalRegistryList(&model.ListOptions{All: true}) if err != nil { return nil, err } - registries = append(registries, list...) + for _, reg := range list { + if _, ok := exists[reg.Address]; ok { + continue + } + exists[reg.Address] = struct{}{} + registries = append(registries, reg) + } } - return model.ApplyPagination(p, registries), nil + + return append(registries, dbRegistries...), nil } func (c *combined) RegistryCreate(repo *model.Repo, registry *model.Registry) error { @@ -64,6 +82,86 @@ func (c *combined) RegistryUpdate(repo *model.Repo, registry *model.Registry) er return c.dbRegistry.RegistryUpdate(repo, registry) } -func (c *combined) RegistryDelete(repo *model.Repo, name string) error { - return c.dbRegistry.RegistryDelete(repo, name) +func (c *combined) RegistryDelete(repo *model.Repo, addr string) error { + return c.dbRegistry.RegistryDelete(repo, addr) +} + +func (c *combined) OrgRegistryFind(owner int64, addr string) (*model.Registry, error) { + return c.dbRegistry.OrgRegistryFind(owner, addr) +} + +func (c *combined) OrgRegistryList(owner int64, p *model.ListOptions) ([]*model.Registry, error) { + return c.dbRegistry.OrgRegistryList(owner, p) +} + +func (c *combined) OrgRegistryCreate(owner int64, registry *model.Registry) error { + return c.dbRegistry.OrgRegistryCreate(owner, registry) +} + +func (c *combined) OrgRegistryUpdate(owner int64, registry *model.Registry) error { + return c.dbRegistry.OrgRegistryUpdate(owner, registry) +} + +func (c *combined) OrgRegistryDelete(owner int64, addr string) error { + return c.dbRegistry.OrgRegistryDelete(owner, addr) +} + +func (c *combined) GlobalRegistryFind(addr string) (*model.Registry, error) { + registry, err := c.dbRegistry.GlobalRegistryFind(addr) + if err != nil && !errors.Is(err, types.RecordNotExist) { + return nil, err + } + if registry != nil { + return registry, nil + } + for _, reg := range c.registries { + if registry, err := reg.GlobalRegistryFind(addr); err == nil { + return registry, nil + } + } + return nil, types.RecordNotExist +} + +func (c *combined) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) { + dbRegistries, err := c.dbRegistry.GlobalRegistryList(&model.ListOptions{All: true}) + if err != nil { + return nil, err + } + + registries := make([]*model.Registry, 0, len(dbRegistries)) + exists := make(map[string]struct{}, len(dbRegistries)) + + // Assign database stored registries to the map to avoid duplicates + // from the combined registries so to prioritize ones in database. + for _, reg := range dbRegistries { + exists[reg.Address] = struct{}{} + } + + for _, registry := range c.registries { + list, err := registry.GlobalRegistryList(&model.ListOptions{All: true}) + if err != nil { + return nil, err + } + for _, reg := range list { + if _, ok := exists[reg.Address]; ok { + continue + } + exists[reg.Address] = struct{}{} + registries = append(registries, reg) + } + } + + return model.ApplyPagination(p, append(registries, dbRegistries...)), nil +} + +func (c *combined) GlobalRegistryCreate(registry *model.Registry) error { + return c.dbRegistry.GlobalRegistryCreate(registry) +} + +func (c *combined) GlobalRegistryUpdate(registry *model.Registry) error { + return c.dbRegistry.GlobalRegistryUpdate(registry) +} + +func (c *combined) GlobalRegistryDelete(addr string) error { + return c.dbRegistry.GlobalRegistryDelete(addr) } diff --git a/server/services/registry/db.go b/server/services/registry/db.go index 1160905b6..6e566d27c 100644 --- a/server/services/registry/db.go +++ b/server/services/registry/db.go @@ -28,12 +28,45 @@ func NewDB(store store.Store) Service { return &db{store} } -func (d *db) RegistryFind(repo *model.Repo, name string) (*model.Registry, error) { - return d.store.RegistryFind(repo, name) +func (d *db) RegistryFind(repo *model.Repo, addr string) (*model.Registry, error) { + return d.store.RegistryFind(repo, addr) } func (d *db) RegistryList(repo *model.Repo, p *model.ListOptions) ([]*model.Registry, error) { - return d.store.RegistryList(repo, p) + return d.store.RegistryList(repo, false, p) +} + +func (d *db) RegistryListPipeline(repo *model.Repo, _ *model.Pipeline) ([]*model.Registry, error) { + r, err := d.store.RegistryList(repo, true, &model.ListOptions{All: true}) + if err != nil { + return nil, err + } + + // Return only registries with unique address + // Priority order in case of duplicate addresses are repository, user/organization, global + registries := make([]*model.Registry, 0, len(r)) + uniq := make(map[string]struct{}) + for _, condition := range []struct { + IsRepository bool + IsOrganization bool + IsGlobal bool + }{ + {IsRepository: true}, + {IsOrganization: true}, + {IsGlobal: true}, + } { + for _, registry := range r { + if registry.IsRepository() != condition.IsRepository || registry.IsOrganization() != condition.IsOrganization || registry.IsGlobal() != condition.IsGlobal { + continue + } + if _, ok := uniq[registry.Address]; ok { + continue + } + uniq[registry.Address] = struct{}{} + registries = append(registries, registry) + } + } + return registries, nil } func (d *db) RegistryCreate(_ *model.Repo, in *model.Registry) error { @@ -45,5 +78,57 @@ func (d *db) RegistryUpdate(_ *model.Repo, in *model.Registry) error { } func (d *db) RegistryDelete(repo *model.Repo, addr string) error { - return d.store.RegistryDelete(repo, addr) + registry, err := d.store.RegistryFind(repo, addr) + if err != nil { + return err + } + return d.store.RegistryDelete(registry) +} + +func (d *db) OrgRegistryFind(owner int64, name string) (*model.Registry, error) { + return d.store.OrgRegistryFind(owner, name) +} + +func (d *db) OrgRegistryList(owner int64, p *model.ListOptions) ([]*model.Registry, error) { + return d.store.OrgRegistryList(owner, p) +} + +func (d *db) OrgRegistryCreate(_ int64, in *model.Registry) error { + return d.store.RegistryCreate(in) +} + +func (d *db) OrgRegistryUpdate(_ int64, in *model.Registry) error { + return d.store.RegistryUpdate(in) +} + +func (d *db) OrgRegistryDelete(owner int64, addr string) error { + registry, err := d.store.OrgRegistryFind(owner, addr) + if err != nil { + return err + } + return d.store.RegistryDelete(registry) +} + +func (d *db) GlobalRegistryFind(addr string) (*model.Registry, error) { + return d.store.GlobalRegistryFind(addr) +} + +func (d *db) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) { + return d.store.GlobalRegistryList(p) +} + +func (d *db) GlobalRegistryCreate(in *model.Registry) error { + return d.store.RegistryCreate(in) +} + +func (d *db) GlobalRegistryUpdate(in *model.Registry) error { + return d.store.RegistryUpdate(in) +} + +func (d *db) GlobalRegistryDelete(addr string) error { + registry, err := d.store.GlobalRegistryFind(addr) + if err != nil { + return err + } + return d.store.RegistryDelete(registry) } diff --git a/server/services/registry/filesystem.go b/server/services/registry/filesystem.go index 8faf400f5..959c69834 100644 --- a/server/services/registry/filesystem.go +++ b/server/services/registry/filesystem.go @@ -25,6 +25,7 @@ import ( "github.com/docker/cli/cli/config/types" "go.woodpecker-ci.org/woodpecker/v2/server/model" + model_types "go.woodpecker-ci.org/woodpecker/v2/server/store/types" ) type filesystem struct { @@ -79,17 +80,29 @@ func parseDockerConfig(path string) ([]*model.Registry, error) { Address: key, Username: auth.Username, Password: auth.Password, + ReadOnly: true, }) } return registries, nil } -func (f *filesystem) RegistryFind(*model.Repo, string) (*model.Registry, error) { - return nil, nil +func (f *filesystem) GlobalRegistryFind(addr string) (*model.Registry, error) { + registries, err := f.GlobalRegistryList(&model.ListOptions{All: true}) + if err != nil { + return nil, err + } + + for _, reg := range registries { + if reg.Address == addr { + return reg, nil + } + } + + return nil, model_types.RecordNotExist } -func (f *filesystem) RegistryList(_ *model.Repo, p *model.ListOptions) ([]*model.Registry, error) { +func (f *filesystem) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) { regs, err := parseDockerConfig(f.path) if err != nil { return nil, err diff --git a/server/services/registry/service.go b/server/services/registry/service.go index 252ce9d9a..4198f2ff5 100644 --- a/server/services/registry/service.go +++ b/server/services/registry/service.go @@ -18,15 +18,29 @@ import "go.woodpecker-ci.org/woodpecker/v2/server/model" // Service defines a service for managing registries. type Service interface { + RegistryListPipeline(*model.Repo, *model.Pipeline) ([]*model.Registry, error) + // Repository registries RegistryFind(*model.Repo, string) (*model.Registry, error) RegistryList(*model.Repo, *model.ListOptions) ([]*model.Registry, error) RegistryCreate(*model.Repo, *model.Registry) error RegistryUpdate(*model.Repo, *model.Registry) error RegistryDelete(*model.Repo, string) error + // Organization registries + OrgRegistryFind(int64, string) (*model.Registry, error) + OrgRegistryList(int64, *model.ListOptions) ([]*model.Registry, error) + OrgRegistryCreate(int64, *model.Registry) error + OrgRegistryUpdate(int64, *model.Registry) error + OrgRegistryDelete(int64, string) error + // Global registries + GlobalRegistryFind(string) (*model.Registry, error) + GlobalRegistryList(*model.ListOptions) ([]*model.Registry, error) + GlobalRegistryCreate(*model.Registry) error + GlobalRegistryUpdate(*model.Registry) error + GlobalRegistryDelete(string) error } // ReadOnlyService defines a service for managing registries. type ReadOnlyService interface { - RegistryFind(*model.Repo, string) (*model.Registry, error) - RegistryList(*model.Repo, *model.ListOptions) ([]*model.Registry, error) + GlobalRegistryFind(string) (*model.Registry, error) + GlobalRegistryList(*model.ListOptions) ([]*model.Registry, error) } diff --git a/server/services/secret/db.go b/server/services/secret/db.go index a89dacf2a..52231ca9e 100644 --- a/server/services/secret/db.go +++ b/server/services/secret/db.go @@ -36,8 +36,8 @@ func (d *db) SecretList(repo *model.Repo, p *model.ListOptions) ([]*model.Secret return d.store.SecretList(repo, false, p) } -func (d *db) SecretListPipeline(repo *model.Repo, _ *model.Pipeline, p *model.ListOptions) ([]*model.Secret, error) { - s, err := d.store.SecretList(repo, true, p) +func (d *db) SecretListPipeline(repo *model.Repo, _ *model.Pipeline) ([]*model.Secret, error) { + s, err := d.store.SecretList(repo, true, &model.ListOptions{All: true}) if err != nil { return nil, err } diff --git a/server/services/secret/db_test.go b/server/services/secret/db_test.go index 6f6099f57..cd1112373 100644 --- a/server/services/secret/db_test.go +++ b/server/services/secret/db_test.go @@ -64,7 +64,7 @@ func TestSecretListPipeline(t *testing.T) { repoSecret, }, nil) - s, err := secret.NewDB(mockStore).SecretListPipeline(&model.Repo{}, &model.Pipeline{}, &model.ListOptions{}) + s, err := secret.NewDB(mockStore).SecretListPipeline(&model.Repo{}, &model.Pipeline{}) g.Assert(err).IsNil() g.Assert(len(s)).Equal(1) @@ -77,7 +77,7 @@ func TestSecretListPipeline(t *testing.T) { orgSecret, }, nil) - s, err := secret.NewDB(mockStore).SecretListPipeline(&model.Repo{}, &model.Pipeline{}, &model.ListOptions{}) + s, err := secret.NewDB(mockStore).SecretListPipeline(&model.Repo{}, &model.Pipeline{}) g.Assert(err).IsNil() g.Assert(len(s)).Equal(1) @@ -89,7 +89,7 @@ func TestSecretListPipeline(t *testing.T) { globalSecret, }, nil) - s, err := secret.NewDB(mockStore).SecretListPipeline(&model.Repo{}, &model.Pipeline{}, &model.ListOptions{}) + s, err := secret.NewDB(mockStore).SecretListPipeline(&model.Repo{}, &model.Pipeline{}) g.Assert(err).IsNil() g.Assert(len(s)).Equal(1) diff --git a/server/services/secret/service.go b/server/services/secret/service.go index 24568c195..d505de0a1 100644 --- a/server/services/secret/service.go +++ b/server/services/secret/service.go @@ -18,7 +18,7 @@ import "go.woodpecker-ci.org/woodpecker/v2/server/model" // Service defines a service for managing secrets. type Service interface { - SecretListPipeline(*model.Repo, *model.Pipeline, *model.ListOptions) ([]*model.Secret, error) + SecretListPipeline(*model.Repo, *model.Pipeline) ([]*model.Secret, error) // Repository secrets SecretFind(*model.Repo, string) (*model.Secret, error) SecretList(*model.Repo, *model.ListOptions) ([]*model.Secret, error) diff --git a/server/store/datastore/migration/032_registries_add_user.go b/server/store/datastore/migration/032_registries_add_user.go new file mode 100644 index 000000000..df8d29eb6 --- /dev/null +++ b/server/store/datastore/migration/032_registries_add_user.go @@ -0,0 +1,33 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package migration + +import ( + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +var alterTableRegistriesFixRequiredFields = xormigrate.Migration{ + ID: "alter-table-registries-fix-required-fields", + MigrateSession: func(sess *xorm.Session) error { + if err := alterColumnDefault(sess, "registries", "repo_id", "0"); err != nil { + return err + } + if err := alterColumnNull(sess, "registries", "repo_id", false); err != nil { + return err + } + return alterColumnNull(sess, "registries", "address", false) + }, +} diff --git a/server/store/datastore/migration/common.go b/server/store/datastore/migration/common.go index 23a51ecd0..f562caa7b 100644 --- a/server/store/datastore/migration/common.go +++ b/server/store/datastore/migration/common.go @@ -195,6 +195,7 @@ func alterColumnDefault(sess *xorm.Session, table, column, defValue string) erro } } +//nolint:unparam func alterColumnNull(sess *xorm.Session, table, column string, null bool) error { val := "NULL" if !null { diff --git a/server/store/datastore/migration/migration.go b/server/store/datastore/migration/migration.go index bcaac4e31..bdcead5d9 100644 --- a/server/store/datastore/migration/migration.go +++ b/server/store/datastore/migration/migration.go @@ -61,6 +61,7 @@ var migrationTasks = []*xormigrate.Migration{ &cleanRegistryPipeline, &setForgeID, &unifyColumnsTables, + &alterTableRegistriesFixRequiredFields, } var allBeans = []any{ diff --git a/server/store/datastore/registry.go b/server/store/datastore/registry.go index 924b9a7e1..49687783e 100644 --- a/server/store/datastore/registry.go +++ b/server/store/datastore/registry.go @@ -20,6 +20,8 @@ import ( "go.woodpecker-ci.org/woodpecker/v2/server/model" ) +const orderRegistriesBy = "id" + func (s storage) RegistryFind(repo *model.Repo, addr string) (*model.Registry, error) { reg := new(model.Registry) return reg, wrapGet(s.engine.Where( @@ -27,9 +29,19 @@ func (s storage) RegistryFind(repo *model.Repo, addr string) (*model.Registry, e ).Get(reg)) } -func (s storage) RegistryList(repo *model.Repo, p *model.ListOptions) ([]*model.Registry, error) { +func (s storage) RegistryList(repo *model.Repo, includeGlobalAndOrg bool, p *model.ListOptions) ([]*model.Registry, error) { var regs []*model.Registry - return regs, s.paginate(p).OrderBy("id").Where("repo_id = ?", repo.ID).Find(®s) + var cond builder.Cond = builder.Eq{"repo_id": repo.ID} + if includeGlobalAndOrg { + cond = cond.Or(builder.Eq{"org_id": repo.OrgID}). + Or(builder.And(builder.Eq{"org_id": 0}, builder.Eq{"repo_id": 0})) + } + return regs, s.paginate(p).Where(cond).OrderBy(orderRegistriesBy).Find(®s) +} + +func (s storage) RegistryListAll() ([]*model.Registry, error) { + var registries []*model.Registry + return registries, s.engine.Find(®istries) } func (s storage) RegistryCreate(registry *model.Registry) error { @@ -43,10 +55,32 @@ func (s storage) RegistryUpdate(registry *model.Registry) error { return err } -func (s storage) RegistryDelete(repo *model.Repo, addr string) error { - registry, err := s.RegistryFind(repo, addr) - if err != nil { - return err - } +func (s storage) RegistryDelete(registry *model.Registry) error { return wrapDelete(s.engine.ID(registry.ID).Delete(new(model.Registry))) } + +func (s storage) OrgRegistryFind(orgID int64, name string) (*model.Registry, error) { + registry := new(model.Registry) + return registry, wrapGet(s.engine.Where( + builder.Eq{"org_id": orgID, "address": name}, + ).Get(registry)) +} + +func (s storage) OrgRegistryList(orgID int64, p *model.ListOptions) ([]*model.Registry, error) { + registries := make([]*model.Registry, 0) + return registries, s.paginate(p).Where("org_id = ?", orgID).OrderBy(orderRegistriesBy).Find(®istries) +} + +func (s storage) GlobalRegistryFind(name string) (*model.Registry, error) { + registry := new(model.Registry) + return registry, wrapGet(s.engine.Where( + builder.Eq{"org_id": 0, "repo_id": 0, "address": name}, + ).Get(registry)) +} + +func (s storage) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) { + registries := make([]*model.Registry, 0) + return registries, s.paginate(p).Where( + builder.Eq{"org_id": 0, "repo_id": 0}, + ).OrderBy(orderRegistriesBy).Find(®istries) +} diff --git a/server/store/datastore/registry_test.go b/server/store/datastore/registry_test.go index 35c7bfc14..e6f3f5f05 100644 --- a/server/store/datastore/registry_test.go +++ b/server/store/datastore/registry_test.go @@ -18,6 +18,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v2/server/model" "go.woodpecker-ci.org/woodpecker/v2/server/store/types" @@ -60,7 +61,7 @@ func TestRegistryList(t *testing.T) { Password: "bar", })) - list, err := store.RegistryList(&model.Repo{ID: 1}, &model.ListOptions{Page: 1, PerPage: 50}) + list, err := store.RegistryList(&model.Repo{ID: 1}, false, &model.ListOptions{Page: 1, PerPage: 50}) assert.NoError(t, err) assert.Len(t, list, 2) } @@ -117,6 +118,88 @@ func TestRegistryDelete(t *testing.T) { return } - assert.NoError(t, store.RegistryDelete(&model.Repo{ID: 1}, "index.docker.io")) - assert.ErrorIs(t, store.RegistryDelete(&model.Repo{ID: 1}, "index.docker.io"), types.RecordNotExist) + assert.NoError(t, store.RegistryDelete(reg1)) + assert.ErrorIs(t, store.RegistryDelete(reg1), types.RecordNotExist) +} + +func createTestRegistries(t *testing.T, store *storage) { + assert.NoError(t, store.RegistryCreate(&model.Registry{ + OrgID: 12, + Address: "my.regsitry.local", + })) + assert.NoError(t, store.RegistryCreate(&model.Registry{ + RepoID: 1, + Address: "private.registry.local", + })) + assert.NoError(t, store.RegistryCreate(&model.Registry{ + RepoID: 1, + Address: "very-private.registry.local", + })) + assert.NoError(t, store.RegistryCreate(&model.Registry{ + Address: "index.docker.io", + })) +} + +func TestOrgRegistryFind(t *testing.T) { + store, closer := newTestStore(t, new(model.Registry)) + defer closer() + + err := store.RegistryCreate(&model.Registry{ + OrgID: 12, + Address: "my.regsitry.local", + Username: "username", + Password: "password", + }) + assert.NoError(t, err) + + registry, err := store.OrgRegistryFind(12, "my.regsitry.local") + assert.NoError(t, err) + assert.EqualValues(t, 12, registry.OrgID) + assert.Equal(t, "my.regsitry.local", registry.Address) + assert.Equal(t, "username", registry.Username) + assert.Equal(t, "password", registry.Password) +} + +func TestOrgRegistryList(t *testing.T) { + store, closer := newTestStore(t, new(model.Registry)) + defer closer() + + createTestRegistries(t, store) + + list, err := store.OrgRegistryList(12, &model.ListOptions{All: true}) + assert.NoError(t, err) + require.Len(t, list, 1) + + assert.True(t, list[0].IsOrganization()) +} + +func TestGlobalRegistryFind(t *testing.T) { + store, closer := newTestStore(t, new(model.Registry)) + defer closer() + + err := store.RegistryCreate(&model.Registry{ + Address: "my.regsitry.local", + Username: "username", + Password: "password", + }) + assert.NoError(t, err) + + registry, err := store.GlobalRegistryFind("my.regsitry.local") + assert.NoError(t, err) + assert.Equal(t, "my.regsitry.local", registry.Address) + assert.Equal(t, "username", registry.Username) + assert.Equal(t, "password", registry.Password) +} + +func TestGlobalRegistryList(t *testing.T) { + store, closer := newTestStore(t, new(model.Registry)) + defer closer() + + createTestRegistries(t, store) + + list, err := store.GlobalRegistryList(&model.ListOptions{All: true}) + assert.NoError(t, err) + assert.Len(t, list, 1) + + assert.True(t, list[0].IsGlobal()) } diff --git a/server/store/mocks/store.go b/server/store/mocks/store.go index bfafcc6d4..e0c2663ff 100644 --- a/server/store/mocks/store.go +++ b/server/store/mocks/store.go @@ -1190,6 +1190,66 @@ func (_m *Store) GetUserRemoteID(_a0 model.ForgeRemoteID, _a1 string) (*model.Us return r0, r1 } +// GlobalRegistryFind provides a mock function with given fields: _a0 +func (_m *Store) GlobalRegistryFind(_a0 string) (*model.Registry, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for GlobalRegistryFind") + } + + var r0 *model.Registry + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.Registry, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(string) *model.Registry); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Registry) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GlobalRegistryList provides a mock function with given fields: _a0 +func (_m *Store) GlobalRegistryList(_a0 *model.ListOptions) ([]*model.Registry, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for GlobalRegistryList") + } + + var r0 []*model.Registry + var r1 error + if rf, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Registry, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(*model.ListOptions) []*model.Registry); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Registry) + } + } + + if rf, ok := ret.Get(1).(func(*model.ListOptions) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GlobalSecretFind provides a mock function with given fields: _a0 func (_m *Store) GlobalSecretFind(_a0 string) (*model.Secret, error) { ret := _m.Called(_a0) @@ -1488,6 +1548,66 @@ func (_m *Store) OrgList(_a0 *model.ListOptions) ([]*model.Org, error) { return r0, r1 } +// OrgRegistryFind provides a mock function with given fields: _a0, _a1 +func (_m *Store) OrgRegistryFind(_a0 int64, _a1 string) (*model.Registry, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for OrgRegistryFind") + } + + var r0 *model.Registry + var r1 error + if rf, ok := ret.Get(0).(func(int64, string) (*model.Registry, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(int64, string) *model.Registry); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Registry) + } + } + + if rf, ok := ret.Get(1).(func(int64, string) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// OrgRegistryList provides a mock function with given fields: _a0, _a1 +func (_m *Store) OrgRegistryList(_a0 int64, _a1 *model.ListOptions) ([]*model.Registry, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for OrgRegistryList") + } + + var r0 []*model.Registry + var r1 error + if rf, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Registry, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Registry); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Registry) + } + } + + if rf, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // OrgRepoList provides a mock function with given fields: _a0, _a1 func (_m *Store) OrgRepoList(_a0 *model.Org, _a1 *model.ListOptions) ([]*model.Repo, error) { ret := _m.Called(_a0, _a1) @@ -1698,17 +1818,17 @@ func (_m *Store) RegistryCreate(_a0 *model.Registry) error { return r0 } -// RegistryDelete provides a mock function with given fields: repo, addr -func (_m *Store) RegistryDelete(repo *model.Repo, addr string) error { - ret := _m.Called(repo, addr) +// RegistryDelete provides a mock function with given fields: _a0 +func (_m *Store) RegistryDelete(_a0 *model.Registry) error { + ret := _m.Called(_a0) if len(ret) == 0 { panic("no return value specified for RegistryDelete") } var r0 error - if rf, ok := ret.Get(0).(func(*model.Repo, string) error); ok { - r0 = rf(repo, addr) + if rf, ok := ret.Get(0).(func(*model.Registry) error); ok { + r0 = rf(_a0) } else { r0 = ret.Error(0) } @@ -1746,9 +1866,9 @@ func (_m *Store) RegistryFind(_a0 *model.Repo, _a1 string) (*model.Registry, err return r0, r1 } -// RegistryList provides a mock function with given fields: _a0, _a1 -func (_m *Store) RegistryList(_a0 *model.Repo, _a1 *model.ListOptions) ([]*model.Registry, error) { - ret := _m.Called(_a0, _a1) +// RegistryList provides a mock function with given fields: _a0, _a1, _a2 +func (_m *Store) RegistryList(_a0 *model.Repo, _a1 bool, _a2 *model.ListOptions) ([]*model.Registry, error) { + ret := _m.Called(_a0, _a1, _a2) if len(ret) == 0 { panic("no return value specified for RegistryList") @@ -1756,19 +1876,49 @@ func (_m *Store) RegistryList(_a0 *model.Repo, _a1 *model.ListOptions) ([]*model var r0 []*model.Registry var r1 error - if rf, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) ([]*model.Registry, error)); ok { - return rf(_a0, _a1) + if rf, ok := ret.Get(0).(func(*model.Repo, bool, *model.ListOptions) ([]*model.Registry, error)); ok { + return rf(_a0, _a1, _a2) } - if rf, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) []*model.Registry); ok { - r0 = rf(_a0, _a1) + if rf, ok := ret.Get(0).(func(*model.Repo, bool, *model.ListOptions) []*model.Registry); ok { + r0 = rf(_a0, _a1, _a2) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Registry) } } - if rf, ok := ret.Get(1).(func(*model.Repo, *model.ListOptions) error); ok { - r1 = rf(_a0, _a1) + if rf, ok := ret.Get(1).(func(*model.Repo, bool, *model.ListOptions) error); ok { + r1 = rf(_a0, _a1, _a2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RegistryListAll provides a mock function with given fields: +func (_m *Store) RegistryListAll() ([]*model.Registry, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for RegistryListAll") + } + + var r0 []*model.Registry + var r1 error + if rf, ok := ret.Get(0).(func() ([]*model.Registry, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []*model.Registry); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Registry) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() } else { r1 = ret.Error(1) } diff --git a/server/store/store.go b/server/store/store.go index ecf33ad64..8ff3b1052 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -120,10 +120,15 @@ type Store interface { // Registries RegistryFind(*model.Repo, string) (*model.Registry, error) - RegistryList(*model.Repo, *model.ListOptions) ([]*model.Registry, error) + RegistryList(*model.Repo, bool, *model.ListOptions) ([]*model.Registry, error) + RegistryListAll() ([]*model.Registry, error) RegistryCreate(*model.Registry) error RegistryUpdate(*model.Registry) error - RegistryDelete(repo *model.Repo, addr string) error + RegistryDelete(*model.Registry) error + OrgRegistryFind(int64, string) (*model.Registry, error) + OrgRegistryList(int64, *model.ListOptions) ([]*model.Registry, error) + GlobalRegistryFind(string) (*model.Registry, error) + GlobalRegistryList(*model.ListOptions) ([]*model.Registry, error) // Steps StepLoad(int64) (*model.Step, error) diff --git a/web/components.d.ts b/web/components.d.ts index 72c5ae49e..6fab8d9e3 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -14,6 +14,7 @@ declare module 'vue' { AdminOrgsTab: typeof import('./src/components/admin/settings/AdminOrgsTab.vue')['default'] AdminQueueStats: typeof import('./src/components/admin/settings/queue/AdminQueueStats.vue')['default'] AdminQueueTab: typeof import('./src/components/admin/settings/AdminQueueTab.vue')['default'] + AdminRegistriesTab: typeof import('./src/components/admin/settings/AdminRegistriesTab.vue')['default'] AdminReposTab: typeof import('./src/components/admin/settings/AdminReposTab.vue')['default'] AdminSecretsTab: typeof import('./src/components/admin/settings/AdminSecretsTab.vue')['default'] AdminUsersTab: typeof import('./src/components/admin/settings/AdminUsersTab.vue')['default'] @@ -66,12 +67,12 @@ declare module 'vue' { IMdiPlay: typeof import('~icons/mdi/play')['default'] IMdiRadioboxBlank: typeof import('~icons/mdi/radiobox-blank')['default'] IMdiRadioboxIndeterminateVariant: typeof import('~icons/mdi/radiobox-indeterminate-variant')['default'] + IMdiSync: typeof import('~icons/mdi/sync')['default'] IMdiSourceBranch: typeof import('~icons/mdi/source-branch')['default'] IMdiSourceCommit: typeof import('~icons/mdi/source-commit')['default'] IMdiSourceMerge: typeof import('~icons/mdi/source-merge')['default'] IMdiSourcePull: typeof import('~icons/mdi/source-pull')['default'] IMdiStop: typeof import('~icons/mdi/stop')['default'] - IMdiSync: typeof import('~icons/mdi/sync')['default'] IMdiTagOutline: typeof import('~icons/mdi/tag-outline')['default'] InputField: typeof import('./src/components/form/InputField.vue')['default'] IPhGitlabLogoSimpleFill: typeof import('~icons/ph/gitlab-logo-simple-fill')['default'] @@ -85,6 +86,7 @@ declare module 'vue' { ManualPipelinePopup: typeof import('./src/components/layout/popups/ManualPipelinePopup.vue')['default'] Navbar: typeof import('./src/components/layout/header/Navbar.vue')['default'] NumberField: typeof import('./src/components/form/NumberField.vue')['default'] + OrgRegistriesTab: typeof import('./src/components/org/settings/OrgRegistriesTab.vue')['default'] OrgSecretsTab: typeof import('./src/components/org/settings/OrgSecretsTab.vue')['default'] Panel: typeof import('./src/components/layout/Panel.vue')['default'] PipelineFeedItem: typeof import('./src/components/pipeline-feed/PipelineFeedItem.vue')['default'] @@ -98,7 +100,9 @@ declare module 'vue' { PipelineStepList: typeof import('./src/components/repo/pipeline/PipelineStepList.vue')['default'] Popup: typeof import('./src/components/layout/Popup.vue')['default'] RadioField: typeof import('./src/components/form/RadioField.vue')['default'] + RegistryEdit: typeof import('./src/components/registry/RegistryEdit.vue')['default'] RegistriesTab: typeof import('./src/components/repo/settings/RegistriesTab.vue')['default'] + RegistryList: typeof import('./src/components/registry/RegistryList.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] Scaffold: typeof import('./src/components/layout/scaffold/Scaffold.vue')['default'] diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index 646b965bc..d6b9338e8 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -122,24 +122,6 @@ "desc": "Enable to cancel pending and running pipelines of the same event and context before starting the newly triggered one." } }, - "registries": { - "registries": "Registries", - "credentials": "Registry credentials", - "desc": "Registries credentials can be added to use private images for your pipeline.", - "show": "Show registries", - "add": "Add registry", - "none": "There are no registry credentials yet.", - "save": "Save registry", - "created": "Registry credentials created", - "saved": "Registry credentials saved", - "deleted": "Registry credentials deleted", - "address": { - "address": "Address", - "placeholder": "Registry Address (e.g. docker.io)" - }, - "edit": "Edit registry", - "delete": "Delete registry" - }, "crons": { "crons": "Crons", "desc": "Cron jobs can be used to trigger pipelines on a regular basis.", @@ -266,6 +248,9 @@ "not_allowed": "You are not allowed to access this organization's settings", "secrets": { "desc": "Organization secrets can be passed to all organization's repository individual pipeline steps at runtime as environmental variables." + }, + "registries": { + "desc": "Organization registry credentials can be added to use private images for all organization's pipelines." } } }, @@ -276,6 +261,10 @@ "desc": "Global secrets can be passed to all repositories individual pipeline steps at runtime as environmental variables.", "warning": "These secrets will be available for all server users." }, + "registries": { + "desc": "Global registry credentials can be added to use private images for all server's pipelines.", + "warning": "These registry creditentials will be available for all server users." + }, "agents": { "agents": "Agents", "desc": "Agents registered for this server", @@ -435,6 +424,26 @@ "edit": "Edit secret", "delete": "Delete secret" }, + "registries": { + "registries": "Registries", + "credentials": "Registry credentials", + "desc": "Registries credentials can be added to use private images for your pipeline.", + "none": "There are no registry credentials yet.", + "address": { + "address": "Address", + "desc": "Registry Address (e.g. docker.io)" + }, + "show": "Show registries", + "save": "Save registry", + "add": "Add registry", + "view": "View registry", + "edit": "Edit registry", + "delete": "Delete registry", + "delete_confirm": "Do you really want to delete this registry?", + "created": "Registry credentials created", + "saved": "Registry credentials saved", + "deleted": "Registry credentials deleted" + }, "default": "default", "info": "Info", "running_version": "You are running Woodpecker {0}", diff --git a/web/src/components/admin/settings/AdminRegistriesTab.vue b/web/src/components/admin/settings/AdminRegistriesTab.vue new file mode 100644 index 000000000..8c7ae8d31 --- /dev/null +++ b/web/src/components/admin/settings/AdminRegistriesTab.vue @@ -0,0 +1,101 @@ + + + diff --git a/web/src/components/layout/scaffold/Tabs.vue b/web/src/components/layout/scaffold/Tabs.vue index cd7701f93..75f91a620 100644 --- a/web/src/components/layout/scaffold/Tabs.vue +++ b/web/src/components/layout/scaffold/Tabs.vue @@ -3,7 +3,7 @@