mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/yunionio/cloudpods.git
synced 2026-09-20 08:03:53 +08:00
fix(ansible): validate playbook inventory, connection, and file paths (#25605)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import (
|
|||||||
"yunion.io/x/jsonutils"
|
"yunion.io/x/jsonutils"
|
||||||
"yunion.io/x/log"
|
"yunion.io/x/log"
|
||||||
"yunion.io/x/pkg/errors"
|
"yunion.io/x/pkg/errors"
|
||||||
|
"yunion.io/x/pkg/tristate"
|
||||||
"yunion.io/x/sqlchemy"
|
"yunion.io/x/sqlchemy"
|
||||||
|
|
||||||
"yunion.io/x/onecloud/pkg/ansibleserver/options"
|
"yunion.io/x/onecloud/pkg/ansibleserver/options"
|
||||||
@@ -43,6 +44,7 @@ import (
|
|||||||
|
|
||||||
type SAnsiblePlaybook struct {
|
type SAnsiblePlaybook struct {
|
||||||
db.SVirtualResourceBase
|
db.SVirtualResourceBase
|
||||||
|
db.SEnabledResourceBase `enabled->default:"" enabled->nullable:"true"`
|
||||||
|
|
||||||
Playbook *ansible.Playbook `length:"text" nullable:"false" create:"required" get:"user" update:"user"`
|
Playbook *ansible.Playbook `length:"text" nullable:"false" create:"required" get:"user" update:"user"`
|
||||||
Output string `length:"medium" get:"user"`
|
Output string `length:"medium" get:"user"`
|
||||||
@@ -52,6 +54,7 @@ type SAnsiblePlaybook struct {
|
|||||||
|
|
||||||
type SAnsiblePlaybookManager struct {
|
type SAnsiblePlaybookManager struct {
|
||||||
db.SVirtualResourceBaseManager
|
db.SVirtualResourceBaseManager
|
||||||
|
db.SEnabledResourceBaseManager
|
||||||
|
|
||||||
sessions ansible.SessionManager
|
sessions ansible.SessionManager
|
||||||
sessionsMux *sync.Mutex
|
sessionsMux *sync.Mutex
|
||||||
@@ -73,6 +76,41 @@ func init() {
|
|||||||
AnsiblePlaybookManager.SetVirtualObject(AnsiblePlaybookManager)
|
AnsiblePlaybookManager.SetVirtualObject(AnsiblePlaybookManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireSystemAdmin is required to enable a playbook.
|
||||||
|
func requireSystemAdmin(userCred mcclient.TokenCredential) error {
|
||||||
|
if userCred == nil || !userCred.HasSystemAdminPrivilege() {
|
||||||
|
return httperrors.NewForbiddenError("enabling ansible playbook requires system admin privilege")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyPlaybookEnabledByCred sets enabled on create/update: system admin
|
||||||
|
// defaults to enabled, others are always disabled.
|
||||||
|
func applyPlaybookEnabledByCred(userCred mcclient.TokenCredential, data *jsonutils.JSONDict) {
|
||||||
|
if userCred != nil && userCred.HasSystemAdminPrivilege() {
|
||||||
|
if !data.Contains("enabled") {
|
||||||
|
data.Set("enabled", jsonutils.JSONTrue)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.Set("enabled", jsonutils.JSONFalse)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensurePlaybookEnabled(enabled bool) error {
|
||||||
|
if !enabled {
|
||||||
|
return httperrors.NewForbiddenError("playbook is not enabled")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (man *SAnsiblePlaybookManager) ListItemFilter(ctx context.Context, q *sqlchemy.SQuery, userCred mcclient.TokenCredential, query api.AnsiblePlaybookListInput) (*sqlchemy.SQuery, error) {
|
||||||
|
q, err := man.SVirtualResourceBaseManager.ListItemFilter(ctx, q, userCred, query.VirtualResourceListInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return man.SEnabledResourceBaseManager.ListItemFilter(ctx, q, userCred, query.EnabledResourceBaseListInput)
|
||||||
|
}
|
||||||
|
|
||||||
func (man *SAnsiblePlaybookManager) ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data *jsonutils.JSONDict) (*jsonutils.JSONDict, error) {
|
func (man *SAnsiblePlaybookManager) ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data *jsonutils.JSONDict) (*jsonutils.JSONDict, error) {
|
||||||
pbV := NewAnsiblePlaybookValidator("playbook", userCred)
|
pbV := NewAnsiblePlaybookValidator("playbook", userCred)
|
||||||
if err := pbV.Validate(ctx, data); err != nil {
|
if err := pbV.Validate(ctx, data); err != nil {
|
||||||
@@ -90,11 +128,15 @@ func (man *SAnsiblePlaybookManager) ValidateCreateData(ctx context.Context, user
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
data.Update(jsonutils.Marshal(input))
|
data.Update(jsonutils.Marshal(input))
|
||||||
|
applyPlaybookEnabledByCred(userCred, data)
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (apb *SAnsiblePlaybook) PostCreate(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data jsonutils.JSONObject) {
|
func (apb *SAnsiblePlaybook) PostCreate(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data jsonutils.JSONObject) {
|
||||||
apb.SVirtualResourceBase.PostCreate(ctx, userCred, ownerId, query, data)
|
apb.SVirtualResourceBase.PostCreate(ctx, userCred, ownerId, query, data)
|
||||||
|
if !apb.GetEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
err := apb.runPlaybook(ctx, userCred)
|
err := apb.runPlaybook(ctx, userCred)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("postCreate: runPlaybook: %v", err)
|
log.Errorf("postCreate: runPlaybook: %v", err)
|
||||||
@@ -118,6 +160,28 @@ func (man *SAnsiblePlaybookManager) InitializeData() error {
|
|||||||
log.Errorf("set playbook %s(%s) to unknown state: %v", pb.Name, pb.Id, err)
|
log.Errorf("set playbook %s(%s) to unknown state: %v", pb.Name, pb.Id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := man.eanbleExistingPlaybooks(); err != nil {
|
||||||
|
return errors.Wrap(err, "enable existing playbooks")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (man *SAnsiblePlaybookManager) eanbleExistingPlaybooks() error {
|
||||||
|
pbs := []SAnsiblePlaybookV2{}
|
||||||
|
q := AnsiblePlaybookV2Manager.Query().IsNull("enabled")
|
||||||
|
if err := db.FetchModelObjects(AnsiblePlaybookV2Manager, q, &pbs); err != nil {
|
||||||
|
return errors.Wrap(err, "fetch running playbooks")
|
||||||
|
}
|
||||||
|
for i := 0; i < len(pbs); i++ {
|
||||||
|
pb := &pbs[i]
|
||||||
|
_, err := db.Update(pb, func() error {
|
||||||
|
pb.Enabled = tristate.True
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("enable playbook %s(%s): %v", pb.Name, pb.Id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,17 +206,44 @@ func (apb *SAnsiblePlaybook) ValidateUpdateData(ctx context.Context, userCred mc
|
|||||||
}
|
}
|
||||||
apb.Playbook = pbV.Playbook // Update as a whole
|
apb.Playbook = pbV.Playbook // Update as a whole
|
||||||
data.Set("status", jsonutils.NewString(api.AnsiblePlaybookStatusInit))
|
data.Set("status", jsonutils.NewString(api.AnsiblePlaybookStatusInit))
|
||||||
|
applyPlaybookEnabledByCred(userCred, data)
|
||||||
|
if enabled, err := data.Bool("enabled"); err == nil {
|
||||||
|
apb.SetEnabled(enabled)
|
||||||
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (apb *SAnsiblePlaybook) PostUpdate(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, data jsonutils.JSONObject) {
|
func (apb *SAnsiblePlaybook) PostUpdate(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, data jsonutils.JSONObject) {
|
||||||
apb.SVirtualResourceBase.PostUpdate(ctx, userCred, query, data)
|
apb.SVirtualResourceBase.PostUpdate(ctx, userCred, query, data)
|
||||||
|
if !apb.GetEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
err := apb.runPlaybook(ctx, userCred)
|
err := apb.runPlaybook(ctx, userCred)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("postUpdate: runPlaybook: %v", err)
|
log.Errorf("postUpdate: runPlaybook: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (apb *SAnsiblePlaybook) PerformEnable(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformEnableInput) (jsonutils.JSONObject, error) {
|
||||||
|
if err := requireSystemAdmin(userCred); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ansible.ValidatePlaybook(apb.Playbook); err != nil {
|
||||||
|
return nil, httperrors.NewInputParameterError("%s", err.Error())
|
||||||
|
}
|
||||||
|
if err := db.EnabledPerformEnable(apb, ctx, userCred, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (apb *SAnsiblePlaybook) PerformDisable(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformDisableInput) (jsonutils.JSONObject, error) {
|
||||||
|
if err := db.EnabledPerformEnable(apb, ctx, userCred, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (apb *SAnsiblePlaybook) PerformRun(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, data jsonutils.JSONObject) (jsonutils.JSONObject, error) {
|
func (apb *SAnsiblePlaybook) PerformRun(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, data jsonutils.JSONObject) (jsonutils.JSONObject, error) {
|
||||||
err := apb.runPlaybook(ctx, userCred)
|
err := apb.runPlaybook(ctx, userCred)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -176,9 +267,15 @@ func (apb *SAnsiblePlaybook) runPlaybook(ctx context.Context, userCred mcclient.
|
|||||||
if man.sessions.Has(apb.Id) {
|
if man.sessions.Has(apb.Id) {
|
||||||
return fmt.Errorf("playbook is already running")
|
return fmt.Errorf("playbook is already running")
|
||||||
}
|
}
|
||||||
|
if err := ensurePlaybookEnabled(apb.GetEnabled()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// init private key
|
// init private key
|
||||||
pb := apb.Playbook.Copy()
|
pb := apb.Playbook.Copy()
|
||||||
|
if err := ansible.ValidatePlaybook(pb); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if len(pb.PrivateKey) == 0 {
|
if len(pb.PrivateKey) == 0 {
|
||||||
if k, err := compute.Sshkeypairs.FetchPrivateKey(ctx, userCred); err != nil {
|
if k, err := compute.Sshkeypairs.FetchPrivateKey(ctx, userCred); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
80
pkg/ansibleserver/models/ansibleplaybooks_test.go
Normal file
80
pkg/ansibleserver/models/ansibleplaybooks_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Copyright 2019 Yunion
|
||||||
|
//
|
||||||
|
// 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 authorized 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 models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"yunion.io/x/jsonutils"
|
||||||
|
"yunion.io/x/onecloud/pkg/mcclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequireSystemAdmin(t *testing.T) {
|
||||||
|
systemAdmin := &mcclient.SSimpleToken{User: "admin", Project: "system", Roles: "admin"}
|
||||||
|
if err := requireSystemAdmin(systemAdmin); err != nil {
|
||||||
|
t.Fatalf("system admin should pass: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant := &mcclient.SSimpleToken{User: "user1", Project: "proj1", Roles: "user"}
|
||||||
|
if err := requireSystemAdmin(tenant); err == nil {
|
||||||
|
t.Fatal("tenant should be forbidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
projectAdmin := &mcclient.SSimpleToken{User: "owner", Project: "proj1", Roles: "admin"}
|
||||||
|
if err := requireSystemAdmin(projectAdmin); err == nil {
|
||||||
|
t.Fatal("project admin (non-system project) should be forbidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := requireSystemAdmin(nil); err == nil {
|
||||||
|
t.Fatal("nil credential should be forbidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyPlaybookEnabledByCred(t *testing.T) {
|
||||||
|
admin := &mcclient.SSimpleToken{User: "admin", Project: "system", Roles: "admin"}
|
||||||
|
tenant := &mcclient.SSimpleToken{User: "user1", Project: "proj1", Roles: "user"}
|
||||||
|
|
||||||
|
data := jsonutils.NewDict()
|
||||||
|
applyPlaybookEnabledByCred(admin, data)
|
||||||
|
enabled, _ := data.Bool("enabled")
|
||||||
|
if !enabled {
|
||||||
|
t.Fatal("admin create should default enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
data = jsonutils.NewDict()
|
||||||
|
data.Set("enabled", jsonutils.JSONFalse)
|
||||||
|
applyPlaybookEnabledByCred(admin, data)
|
||||||
|
enabled, _ = data.Bool("enabled")
|
||||||
|
if enabled {
|
||||||
|
t.Fatal("admin should be able to create disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
data = jsonutils.NewDict()
|
||||||
|
data.Set("enabled", jsonutils.JSONTrue)
|
||||||
|
applyPlaybookEnabledByCred(tenant, data)
|
||||||
|
enabled, _ = data.Bool("enabled")
|
||||||
|
if enabled {
|
||||||
|
t.Fatal("non-admin create must be disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePlaybookEnabled(t *testing.T) {
|
||||||
|
if err := ensurePlaybookEnabled(true); err != nil {
|
||||||
|
t.Fatalf("enabled: %v", err)
|
||||||
|
}
|
||||||
|
if err := ensurePlaybookEnabled(false); err == nil {
|
||||||
|
t.Fatal("disabled playbook should not run")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"yunion.io/x/jsonutils"
|
"yunion.io/x/jsonutils"
|
||||||
"yunion.io/x/log"
|
"yunion.io/x/log"
|
||||||
"yunion.io/x/pkg/errors"
|
"yunion.io/x/pkg/errors"
|
||||||
|
"yunion.io/x/pkg/tristate"
|
||||||
"yunion.io/x/sqlchemy"
|
"yunion.io/x/sqlchemy"
|
||||||
|
|
||||||
"yunion.io/x/onecloud/pkg/ansibleserver/options"
|
"yunion.io/x/onecloud/pkg/ansibleserver/options"
|
||||||
@@ -40,6 +41,7 @@ import (
|
|||||||
// This is at the moment for internal use only. Update is not allowed
|
// This is at the moment for internal use only. Update is not allowed
|
||||||
type SAnsiblePlaybookV2 struct {
|
type SAnsiblePlaybookV2 struct {
|
||||||
db.SVirtualResourceBase
|
db.SVirtualResourceBase
|
||||||
|
db.SEnabledResourceBase `enabled->default:"" enabled->nullable:"true"`
|
||||||
|
|
||||||
Playbook string `length:"text" nullable:"false" create:"required" get:"user"`
|
Playbook string `length:"text" nullable:"false" create:"required" get:"user"`
|
||||||
Inventory string `length:"text" nullable:"false" create:"required" get:"user"`
|
Inventory string `length:"text" nullable:"false" create:"required" get:"user"`
|
||||||
@@ -54,6 +56,7 @@ type SAnsiblePlaybookV2 struct {
|
|||||||
|
|
||||||
type SAnsiblePlaybookV2Manager struct {
|
type SAnsiblePlaybookV2Manager struct {
|
||||||
db.SVirtualResourceBaseManager
|
db.SVirtualResourceBaseManager
|
||||||
|
db.SEnabledResourceBaseManager
|
||||||
|
|
||||||
sessions ansible.SessionManager
|
sessions ansible.SessionManager
|
||||||
sessionsMux *sync.Mutex
|
sessionsMux *sync.Mutex
|
||||||
@@ -75,6 +78,14 @@ func init() {
|
|||||||
AnsiblePlaybookV2Manager.SetVirtualObject(AnsiblePlaybookV2Manager)
|
AnsiblePlaybookV2Manager.SetVirtualObject(AnsiblePlaybookV2Manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (man *SAnsiblePlaybookV2Manager) ListItemFilter(ctx context.Context, q *sqlchemy.SQuery, userCred mcclient.TokenCredential, query api.AnsiblePlaybookListInput) (*sqlchemy.SQuery, error) {
|
||||||
|
q, err := man.SVirtualResourceBaseManager.ListItemFilter(ctx, q, userCred, query.VirtualResourceListInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return man.SEnabledResourceBaseManager.ListItemFilter(ctx, q, userCred, query.EnabledResourceBaseListInput)
|
||||||
|
}
|
||||||
|
|
||||||
func (man *SAnsiblePlaybookV2Manager) ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data *jsonutils.JSONDict) (*jsonutils.JSONDict, error) {
|
func (man *SAnsiblePlaybookV2Manager) ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data *jsonutils.JSONDict) (*jsonutils.JSONDict, error) {
|
||||||
data.Set("status", jsonutils.NewString(api.AnsiblePlaybookStatusInit))
|
data.Set("status", jsonutils.NewString(api.AnsiblePlaybookStatusInit))
|
||||||
input := apis.VirtualResourceCreateInput{}
|
input := apis.VirtualResourceCreateInput{}
|
||||||
@@ -87,11 +98,42 @@ func (man *SAnsiblePlaybookV2Manager) ValidateCreateData(ctx context.Context, us
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
data.Update(jsonutils.Marshal(input))
|
data.Update(jsonutils.Marshal(input))
|
||||||
|
if err := validateAnsiblePlaybookV2Input(userCred, data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
applyPlaybookEnabledByCred(userCred, data)
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateAnsiblePlaybookV2Input(userCred mcclient.TokenCredential, data *jsonutils.JSONDict) error {
|
||||||
|
playbook, _ := data.GetString("playbook")
|
||||||
|
inventory, _ := data.GetString("inventory")
|
||||||
|
files, _ := data.GetString("files")
|
||||||
|
requirements, _ := data.GetString("requirements")
|
||||||
|
return validateAnsiblePlaybookV2Fields(userCred, playbook, inventory, files, requirements)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAnsiblePlaybookV2Fields(userCred mcclient.TokenCredential, playbook, inventory, files, requirements string) error {
|
||||||
|
if err := ansible.ValidatePlaybookYAML(playbook); err != nil {
|
||||||
|
return httperrors.NewInputParameterError("%s", err.Error())
|
||||||
|
}
|
||||||
|
if err := ansible.ValidateInventoryYAML(inventory); err != nil {
|
||||||
|
return httperrors.NewInputParameterError("%s", err.Error())
|
||||||
|
}
|
||||||
|
if err := ansible.ValidateFilesJSON(files); err != nil {
|
||||||
|
return httperrors.NewInputParameterError("%s", err.Error())
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(requirements) != "" && (userCred == nil || !userCred.HasSystemAdminPrivilege()) {
|
||||||
|
return httperrors.NewForbiddenError("requirements is not allowed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (apb *SAnsiblePlaybookV2) PostCreate(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data jsonutils.JSONObject) {
|
func (apb *SAnsiblePlaybookV2) PostCreate(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data jsonutils.JSONObject) {
|
||||||
apb.SVirtualResourceBase.PostCreate(ctx, userCred, ownerId, query, data)
|
apb.SVirtualResourceBase.PostCreate(ctx, userCred, ownerId, query, data)
|
||||||
|
if !apb.GetEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
err := apb.runPlaybook(ctx, userCred)
|
err := apb.runPlaybook(ctx, userCred)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("postCreate: runPlaybook: %v", err)
|
log.Errorf("postCreate: runPlaybook: %v", err)
|
||||||
@@ -115,6 +157,28 @@ func (man *SAnsiblePlaybookV2Manager) InitializeData() error {
|
|||||||
log.Errorf("set playbook %s(%s) to unknown state: %v", pb.Name, pb.Id, err)
|
log.Errorf("set playbook %s(%s) to unknown state: %v", pb.Name, pb.Id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := man.eanbleExistingPlaybooks(); err != nil {
|
||||||
|
return errors.Wrap(err, "enable existing playbooks")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (man *SAnsiblePlaybookV2Manager) eanbleExistingPlaybooks() error {
|
||||||
|
pbs := []SAnsiblePlaybookV2{}
|
||||||
|
q := AnsiblePlaybookV2Manager.Query().IsNull("enabled")
|
||||||
|
if err := db.FetchModelObjects(AnsiblePlaybookV2Manager, q, &pbs); err != nil {
|
||||||
|
return errors.Wrap(err, "fetch running playbooks")
|
||||||
|
}
|
||||||
|
for i := 0; i < len(pbs); i++ {
|
||||||
|
pb := &pbs[i]
|
||||||
|
_, err := db.Update(pb, func() error {
|
||||||
|
pb.Enabled = tristate.True
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("enable playbook %s(%s): %v", pb.Name, pb.Id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +189,26 @@ func (apb *SAnsiblePlaybookV2) ValidateDeleteCondition(ctx context.Context, info
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (apb *SAnsiblePlaybookV2) PerformEnable(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformEnableInput) (jsonutils.JSONObject, error) {
|
||||||
|
if err := requireSystemAdmin(userCred); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateAnsiblePlaybookV2Fields(userCred, apb.Playbook, apb.Inventory, apb.Files, apb.Requirements); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := db.EnabledPerformEnable(apb, ctx, userCred, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (apb *SAnsiblePlaybookV2) PerformDisable(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformDisableInput) (jsonutils.JSONObject, error) {
|
||||||
|
if err := db.EnabledPerformEnable(apb, ctx, userCred, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (apb *SAnsiblePlaybookV2) PerformRun(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, data jsonutils.JSONObject) (jsonutils.JSONObject, error) {
|
func (apb *SAnsiblePlaybookV2) PerformRun(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, data jsonutils.JSONObject) (jsonutils.JSONObject, error) {
|
||||||
err := apb.runPlaybook(ctx, userCred)
|
err := apb.runPlaybook(ctx, userCred)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -148,6 +232,21 @@ func (apb *SAnsiblePlaybookV2) runPlaybook(ctx context.Context, userCred mcclien
|
|||||||
if man.sessions.Has(apb.Id) {
|
if man.sessions.Has(apb.Id) {
|
||||||
return fmt.Errorf("playbook is already running")
|
return fmt.Errorf("playbook is already running")
|
||||||
}
|
}
|
||||||
|
if err := ensurePlaybookEnabled(apb.GetEnabled()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ansible.ValidatePlaybookYAML(apb.Playbook); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ansible.ValidateInventoryYAML(apb.Inventory); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ansible.ValidateFilesJSON(apb.Files); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(apb.Requirements) != "" && !userCred.HasSystemAdminPrivilege() {
|
||||||
|
return httperrors.NewForbiddenError("requirements is not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
// hack: force Sleep 50s to wait some host ssh service started
|
// hack: force Sleep 50s to wait some host ssh service started
|
||||||
// time.Sleep(50 * time.Second)
|
// time.Sleep(50 * time.Second)
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ func (v *ValidatorAnsiblePlaybook) Validate(ctx context.Context, data *jsonutils
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := ansible.ValidatePlaybook(pb); err != nil {
|
||||||
|
return httperrors.NewInputParameterError("%s", err.Error())
|
||||||
|
}
|
||||||
// add LF for privateKey
|
// add LF for privateKey
|
||||||
if len(pb.PrivateKey) > 0 && pb.PrivateKey[len(pb.PrivateKey)-1] != 10 {
|
if len(pb.PrivateKey) > 0 && pb.PrivateKey[len(pb.PrivateKey)-1] != 10 {
|
||||||
pb.PrivateKey = append(pb.PrivateKey, 10)
|
pb.PrivateKey = append(pb.PrivateKey, 10)
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ type AnsiblePlaybookCreateInput struct {
|
|||||||
Playbook ansible.Playbook `json:"playbook"`
|
Playbook ansible.Playbook `json:"playbook"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AnsiblePlaybookListInput struct {
|
||||||
|
apis.VirtualResourceListInput
|
||||||
|
apis.EnabledResourceBaseListInput
|
||||||
|
}
|
||||||
|
|
||||||
type AnsiblePlaybookUpdateInput AnsiblePlaybookCreateInput
|
type AnsiblePlaybookUpdateInput AnsiblePlaybookCreateInput
|
||||||
|
|
||||||
type AnsibleHost struct {
|
type AnsibleHost struct {
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ func (proxyendpoint *SProxyEndpoint) remoteConfigure(ctx context.Context, userCr
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cliSess := auth.GetSession(ctx, userCred, "")
|
// the playbook content here is server-generated; use the admin session
|
||||||
|
// since ansible playbook creation requires system admin privilege
|
||||||
|
cliSess := auth.GetAdminSession(ctx, "")
|
||||||
pbId := ""
|
pbId := ""
|
||||||
pbName := "pe-remote-configure-" + proxyendpoint.Name
|
pbName := "pe-remote-configure-" + proxyendpoint.Name
|
||||||
_, err := ansible_modules.AnsiblePlaybooks.UpdateOrCreatePbModel(
|
_, err := ansible_modules.AnsiblePlaybooks.UpdateOrCreatePbModel(
|
||||||
|
|||||||
@@ -405,9 +405,15 @@ func (guest *SGuest) PerformMakeSshable(
|
|||||||
if input.User == "" {
|
if input.User == "" {
|
||||||
return output, httperrors.NewBadRequestError("missing username")
|
return output, httperrors.NewBadRequestError("missing username")
|
||||||
}
|
}
|
||||||
|
if !ansible.IsValidAnsibleUser(input.User) {
|
||||||
|
return output, httperrors.NewInputParameterError("invalid username")
|
||||||
|
}
|
||||||
if input.PrivateKey == "" && input.Password == "" {
|
if input.PrivateKey == "" && input.Password == "" {
|
||||||
return output, httperrors.NewBadRequestError("private_key and password cannot both be empty")
|
return output, httperrors.NewBadRequestError("private_key and password cannot both be empty")
|
||||||
}
|
}
|
||||||
|
if strings.ContainsAny(input.Password, "\x00\r\n") {
|
||||||
|
return output, httperrors.NewInputParameterError("invalid password")
|
||||||
|
}
|
||||||
|
|
||||||
_, projectPublicKey, err := sshkeys.GetSshProjectKeypair(ctx, guest.ProjectId)
|
_, projectPublicKey, err := sshkeys.GetSshProjectKeypair(ctx, guest.ProjectId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -496,7 +502,9 @@ func (guest *SGuest) PerformMakeSshable(
|
|||||||
host.SetVar("ansible_password", input.Password)
|
host.SetVar("ansible_password", input.Password)
|
||||||
}
|
}
|
||||||
|
|
||||||
cliSess := auth.GetSession(ctx, userCred, "")
|
// the playbook content here is server-generated; use the admin session
|
||||||
|
// since ansible playbook creation requires system admin privilege
|
||||||
|
cliSess := auth.GetAdminSession(ctx, "")
|
||||||
pbId := ""
|
pbId := ""
|
||||||
pbName := "make-sshable-" + guest.Id
|
pbName := "make-sshable-" + guest.Id
|
||||||
pbModel, err := ansible_modules.AnsiblePlaybooks.UpdateOrCreatePbModel(
|
pbModel, err := ansible_modules.AnsiblePlaybooks.UpdateOrCreatePbModel(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package ansible
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Module represents name and args of ansible module to execute
|
// Module represents name and args of ansible module to execute
|
||||||
@@ -74,9 +75,24 @@ func (i *Inventory) Data() []byte {
|
|||||||
b.WriteRune(' ')
|
b.WriteRune(' ')
|
||||||
b.WriteString(k)
|
b.WriteString(k)
|
||||||
b.WriteRune('=')
|
b.WriteRune('=')
|
||||||
b.WriteString(v)
|
b.WriteString(quoteInventoryValue(v))
|
||||||
}
|
}
|
||||||
b.WriteRune('\n')
|
b.WriteRune('\n')
|
||||||
}
|
}
|
||||||
return b.Bytes()
|
return b.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func quoteInventoryValue(v string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(v) + 2)
|
||||||
|
b.WriteByte('"')
|
||||||
|
for i := 0; i < len(v); i++ {
|
||||||
|
c := v[i]
|
||||||
|
if c == '\\' || c == '"' {
|
||||||
|
b.WriteByte('\\')
|
||||||
|
}
|
||||||
|
b.WriteByte(c)
|
||||||
|
}
|
||||||
|
b.WriteByte('"')
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import (
|
|||||||
|
|
||||||
"yunion.io/x/pkg/errors"
|
"yunion.io/x/pkg/errors"
|
||||||
"yunion.io/x/pkg/gotypes"
|
"yunion.io/x/pkg/gotypes"
|
||||||
|
|
||||||
|
"yunion.io/x/onecloud/pkg/util/fileutils2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type pbState int
|
type pbState int
|
||||||
@@ -160,7 +162,11 @@ func (pb *Playbook) Run(ctx context.Context) (err error) {
|
|||||||
|
|
||||||
// write out files
|
// write out files
|
||||||
for name, content := range pb.Files {
|
for name, content := range pb.Files {
|
||||||
path := filepath.Join(tmpdir, name)
|
path, err2 := fileutils2.JoinInside(tmpdir, name)
|
||||||
|
if err2 != nil {
|
||||||
|
err = errors.Wrapf(err2, "playbook file %s", name)
|
||||||
|
return
|
||||||
|
}
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
err = os.MkdirAll(dir, os.FileMode(0700))
|
err = os.MkdirAll(dir, os.FileMode(0700))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
175
pkg/util/ansible/validate.go
Normal file
175
pkg/util/ansible/validate.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
// Copyright 2019 Yunion
|
||||||
|
//
|
||||||
|
// 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 ansible
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"yunion.io/x/pkg/errors"
|
||||||
|
|
||||||
|
"yunion.io/x/onecloud/pkg/util/fileutils2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ansibleUserRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
|
||||||
|
ansibleVarRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
var allowedConnections = map[string]struct{}{
|
||||||
|
"": {},
|
||||||
|
"ssh": {},
|
||||||
|
"smart": {},
|
||||||
|
"paramiko": {},
|
||||||
|
"winrm": {},
|
||||||
|
"psrp": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var deniedAnsibleVars = map[string]struct{}{
|
||||||
|
"ansible_shell_executable": {},
|
||||||
|
"ansible_python_interpreter": {},
|
||||||
|
"ansible_ssh_executable": {},
|
||||||
|
"ansible_remote_tmp": {},
|
||||||
|
"ansible_executable": {},
|
||||||
|
"ansible_shell_type": {},
|
||||||
|
"ansible_become_exe": {},
|
||||||
|
"ansible_become_flags": {},
|
||||||
|
"ansible_ssh_args": {},
|
||||||
|
"ansible_async_dir": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsValidAnsibleUser(user string) bool {
|
||||||
|
return ansibleUserRe.MatchString(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateInventoryHostName(name string) error {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return errors.Errorf("empty host name")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(name, " \t\r\n=") {
|
||||||
|
return errors.Errorf("invalid host name")
|
||||||
|
}
|
||||||
|
if isLocalTarget(name) {
|
||||||
|
return errors.Errorf("host %q is not allowed", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateInventoryVar(key, value string) error {
|
||||||
|
if !ansibleVarRe.MatchString(key) {
|
||||||
|
return errors.Errorf("invalid inventory variable %q", key)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(value, "\x00\r\n") {
|
||||||
|
return errors.Errorf("invalid inventory variable %s", key)
|
||||||
|
}
|
||||||
|
lk := strings.ToLower(key)
|
||||||
|
if isDeniedAnsibleVar(lk) {
|
||||||
|
return errors.Errorf("inventory variable %s is not allowed", key)
|
||||||
|
}
|
||||||
|
switch lk {
|
||||||
|
case "ansible_connection":
|
||||||
|
if _, ok := allowedConnections[strings.ToLower(strings.TrimSpace(value))]; !ok {
|
||||||
|
return errors.Errorf("ansible_connection %q is not allowed", value)
|
||||||
|
}
|
||||||
|
case "ansible_host":
|
||||||
|
if isLocalTarget(value) {
|
||||||
|
return errors.Errorf("ansible_host %q is not allowed", value)
|
||||||
|
}
|
||||||
|
case "ansible_user":
|
||||||
|
if value != "" && !IsValidAnsibleUser(value) {
|
||||||
|
return errors.Errorf("invalid ansible_user")
|
||||||
|
}
|
||||||
|
case "ansible_ssh_private_key_file":
|
||||||
|
if _, err := fileutils2.CleanRelSubpath(value); err != nil {
|
||||||
|
return errors.Wrap(err, "ansible_ssh_private_key_file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateInventoryHost(h Host) error {
|
||||||
|
if err := ValidateInventoryHostName(h.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for k, v := range h.Vars {
|
||||||
|
if err := ValidateInventoryVar(k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidatePlaybookFileName(name string) error {
|
||||||
|
rel, err := fileutils2.CleanRelSubpath(name)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "playbook file %q", name)
|
||||||
|
}
|
||||||
|
base := path.Base(rel)
|
||||||
|
if base == "ansible.cfg" || base == ".ansible.cfg" {
|
||||||
|
return errors.Errorf("playbook file %q is not allowed", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidatePlaybook(pb *Playbook) error {
|
||||||
|
if pb == nil {
|
||||||
|
return errors.Errorf("empty playbook")
|
||||||
|
}
|
||||||
|
for i := range pb.Inventory.Hosts {
|
||||||
|
if err := ValidateInventoryHost(pb.Inventory.Hosts[i]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name := range pb.Files {
|
||||||
|
if err := ValidatePlaybookFileName(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDeniedAnsibleVar(key string) bool {
|
||||||
|
if _, ok := deniedAnsibleVars[key]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(key, "ansible_") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, suffix := range []string{"_executable", "_interpreter", "_extra_args", "_common_args"} {
|
||||||
|
if strings.HasSuffix(key, suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLocalTarget(s string) bool {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch s {
|
||||||
|
case "localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(s)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ip.IsLoopback() || ip.IsUnspecified()
|
||||||
|
}
|
||||||
205
pkg/util/ansible/validate_test.go
Normal file
205
pkg/util/ansible/validate_test.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// Copyright 2019 Yunion
|
||||||
|
//
|
||||||
|
// 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 ansible
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateInventoryHost(t *testing.T) {
|
||||||
|
ok := Host{Name: "10.1.2.3", Vars: map[string]string{
|
||||||
|
"ansible_user": "cloudroot",
|
||||||
|
"ansible_password": "p@ss word",
|
||||||
|
"ansible_become": "yes",
|
||||||
|
"ansible_host": "10.1.2.3",
|
||||||
|
"ansible_port": "22",
|
||||||
|
"repo_base_url": "https://example.com",
|
||||||
|
}}
|
||||||
|
if err := ValidateInventoryHost(ok); err != nil {
|
||||||
|
t.Fatalf("valid host: %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidateInventoryHost(Host{
|
||||||
|
Name: "10.1.2.3",
|
||||||
|
Vars: map[string]string{
|
||||||
|
"ansible_connection": "winrm",
|
||||||
|
"ansible_user": "Administrator",
|
||||||
|
"ansible_password": "secret",
|
||||||
|
"ansible_winrm_transport": "ntlm",
|
||||||
|
"ansible_winrm_server_cert_validation": "ignore",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("winrm host: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateInventoryHost(Host{Name: "127.0.0.1"}); err == nil {
|
||||||
|
t.Fatal("loopback host should fail")
|
||||||
|
}
|
||||||
|
if err := ValidateInventoryHost(Host{Name: "localhost"}); err == nil {
|
||||||
|
t.Fatal("localhost should fail")
|
||||||
|
}
|
||||||
|
if err := ValidateInventoryHost(Host{
|
||||||
|
Name: "10.1.2.3",
|
||||||
|
Vars: map[string]string{"ansible_connection": "local"},
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("local connection should fail")
|
||||||
|
}
|
||||||
|
if err := ValidateInventoryHost(Host{
|
||||||
|
Name: "10.1.2.3",
|
||||||
|
Vars: map[string]string{"ansible_host": "127.0.0.1"},
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("loopback ansible_host should fail")
|
||||||
|
}
|
||||||
|
if err := ValidateInventoryHost(Host{
|
||||||
|
Name: "10.1.2.3",
|
||||||
|
Vars: map[string]string{"ansible_user": "cloudroot ansible_connection=local"},
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("injected ansible_user should fail")
|
||||||
|
}
|
||||||
|
if err := ValidateInventoryHost(Host{
|
||||||
|
Name: "10.1.2.3 cloudroot ansible_connection=local",
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("injected host name should fail")
|
||||||
|
}
|
||||||
|
if err := ValidateInventoryHost(Host{
|
||||||
|
Name: "10.1.2.3",
|
||||||
|
Vars: map[string]string{"ansible_ssh_common_args": "-o ProxyCommand=id"},
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("ssh extra args should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePlaybookFiles(t *testing.T) {
|
||||||
|
pb := &Playbook{
|
||||||
|
Inventory: Inventory{Hosts: []Host{{Name: "10.1.2.3"}}},
|
||||||
|
Files: map[string][]byte{
|
||||||
|
"../etc/passwd": []byte("x"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := ValidatePlaybook(pb); err == nil {
|
||||||
|
t.Fatal("path escape should fail")
|
||||||
|
}
|
||||||
|
pb.Files = map[string][]byte{"ansible.cfg": []byte("x")}
|
||||||
|
if err := ValidatePlaybook(pb); err == nil {
|
||||||
|
t.Fatal("ansible.cfg should fail")
|
||||||
|
}
|
||||||
|
pb.Files = map[string][]byte{"a/b.txt": []byte("x")}
|
||||||
|
if err := ValidatePlaybook(pb); err != nil {
|
||||||
|
t.Fatalf("relative file: %v", err)
|
||||||
|
}
|
||||||
|
pb.Files = map[string][]byte{".id_rsa": []byte("x")}
|
||||||
|
if err := ValidatePlaybook(pb); err != nil {
|
||||||
|
t.Fatalf("dotfile: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInventoryDataQuotesValues(t *testing.T) {
|
||||||
|
inv := Inventory{Hosts: []Host{{
|
||||||
|
Name: "10.1.2.3",
|
||||||
|
Vars: map[string]string{
|
||||||
|
"ansible_user": "cloudroot",
|
||||||
|
"ansible_password": "p@ss word ansible_connection=local",
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
got := string(inv.Data())
|
||||||
|
quoted := quoteInventoryValue("p@ss word ansible_connection=local")
|
||||||
|
if !strings.Contains(got, quoted) {
|
||||||
|
t.Fatalf("expected quoted password %s, got %q", quoted, got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, " ansible_connection=local") && !strings.Contains(got, quoted) {
|
||||||
|
t.Fatalf("unquoted injection: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePlaybookYAML(t *testing.T) {
|
||||||
|
if err := ValidatePlaybookYAML(`- hosts: all
|
||||||
|
become: true
|
||||||
|
tasks:
|
||||||
|
- service:
|
||||||
|
name: network
|
||||||
|
state: restarted
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("valid playbook: %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidatePlaybookYAML(`- hosts: localhost
|
||||||
|
connection: local
|
||||||
|
tasks:
|
||||||
|
- shell: id
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("local playbook should fail")
|
||||||
|
}
|
||||||
|
if err := ValidatePlaybookYAML(`- hosts: all
|
||||||
|
tasks:
|
||||||
|
- command: id
|
||||||
|
delegate_to: localhost
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("delegate_to localhost should fail")
|
||||||
|
}
|
||||||
|
if err := ValidatePlaybookYAML(`- hosts: all,localhost
|
||||||
|
tasks:
|
||||||
|
- ping:
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("hosts list with localhost should fail")
|
||||||
|
}
|
||||||
|
if err := ValidatePlaybookYAML(`- hosts: all
|
||||||
|
tasks:
|
||||||
|
- debug:
|
||||||
|
msg: "{{ lookup('pipe', 'id') }}"
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("lookup should fail")
|
||||||
|
}
|
||||||
|
if err := ValidatePlaybookYAML(`- hosts: all
|
||||||
|
tasks:
|
||||||
|
- local_action: shell id
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("local_action should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateInventoryYAML(t *testing.T) {
|
||||||
|
if err := ValidateInventoryYAML(`all:
|
||||||
|
hosts:
|
||||||
|
gw1:
|
||||||
|
ansible_user: root
|
||||||
|
ansible_host: 10.1.2.3
|
||||||
|
ansible_ssh_private_key_file: .id_rsa
|
||||||
|
ansible_become: yes
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("valid inventory: %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidateInventoryYAML(`all:
|
||||||
|
hosts:
|
||||||
|
x:
|
||||||
|
ansible_connection: local
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("local inventory should fail")
|
||||||
|
}
|
||||||
|
if err := ValidateInventoryYAML(`all:
|
||||||
|
hosts:
|
||||||
|
localhost:
|
||||||
|
ansible_user: root
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("localhost inventory host should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFilesJSON(t *testing.T) {
|
||||||
|
if err := ValidateFilesJSON(`{".id_rsa":"k","wgX.conf.j2":"t"}`); err != nil {
|
||||||
|
t.Fatalf("valid files: %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidateFilesJSON(`{"../etc/passwd":"x"}`); err == nil {
|
||||||
|
t.Fatal("path escape should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
217
pkg/util/ansible/validate_yaml.go
Normal file
217
pkg/util/ansible/validate_yaml.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
// Copyright 2019 Yunion
|
||||||
|
//
|
||||||
|
// 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 ansible
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-yaml/yaml"
|
||||||
|
|
||||||
|
"yunion.io/x/jsonutils"
|
||||||
|
"yunion.io/x/pkg/errors"
|
||||||
|
|
||||||
|
"yunion.io/x/onecloud/pkg/util/fileutils2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var controllerCallRe = regexp.MustCompile(`(?i)(^|[^A-Za-z0-9_])(lookup|query)\s*\(`)
|
||||||
|
|
||||||
|
func ValidatePlaybookYAML(doc string) error {
|
||||||
|
doc = strings.TrimSpace(doc)
|
||||||
|
if doc == "" {
|
||||||
|
return errors.Errorf("empty playbook")
|
||||||
|
}
|
||||||
|
if err := rejectControllerCalls(doc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var parsed interface{}
|
||||||
|
if err := yaml.Unmarshal([]byte(doc), &parsed); err != nil {
|
||||||
|
return errors.Wrap(err, "parse playbook")
|
||||||
|
}
|
||||||
|
return walkYAML(parsed, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateInventoryYAML(doc string) error {
|
||||||
|
doc = strings.TrimSpace(doc)
|
||||||
|
if doc == "" {
|
||||||
|
return errors.Errorf("empty inventory")
|
||||||
|
}
|
||||||
|
if err := rejectControllerCalls(doc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var parsed interface{}
|
||||||
|
if err := yaml.Unmarshal([]byte(doc), &parsed); err != nil {
|
||||||
|
return errors.Wrap(err, "parse inventory")
|
||||||
|
}
|
||||||
|
return walkYAML(parsed, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateFilesJSON(files string) error {
|
||||||
|
files = strings.TrimSpace(files)
|
||||||
|
if files == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
obj, err := jsonutils.ParseString(files)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "parse files")
|
||||||
|
}
|
||||||
|
m, err := obj.GetMap()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "files must be a json object")
|
||||||
|
}
|
||||||
|
for name := range m {
|
||||||
|
if err := ValidatePlaybookFileName(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectControllerCalls(doc string) error {
|
||||||
|
if controllerCallRe.MatchString(doc) {
|
||||||
|
return errors.Errorf("lookup/query is not allowed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkYAML(v interface{}, parent string) error {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case map[interface{}]interface{}:
|
||||||
|
for k, val := range t {
|
||||||
|
key := fmt.Sprint(k)
|
||||||
|
if err := checkYAMLPair(parent, key, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := walkYAML(val, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case map[string]interface{}:
|
||||||
|
for key, val := range t {
|
||||||
|
if err := checkYAMLPair(parent, key, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := walkYAML(val, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []interface{}:
|
||||||
|
for _, item := range t {
|
||||||
|
if s, ok := yamlString(item); ok && (parent == "hosts" || parent == "delegate_to") {
|
||||||
|
if hostsContainLocal(s) {
|
||||||
|
return errors.Errorf("%s %q is not allowed", parent, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := walkYAML(item, parent); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkYAMLPair(parent, key string, val interface{}) error {
|
||||||
|
lk := strings.ToLower(strings.TrimSpace(key))
|
||||||
|
s, isStr := yamlString(val)
|
||||||
|
switch lk {
|
||||||
|
case "connection", "ansible_connection":
|
||||||
|
if !isStr {
|
||||||
|
return errors.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if _, ok := allowedConnections[strings.ToLower(strings.TrimSpace(s))]; !ok {
|
||||||
|
return errors.Errorf("%s %q is not allowed", key, s)
|
||||||
|
}
|
||||||
|
case "delegate_to", "ansible_host":
|
||||||
|
if isStr && hostsContainLocal(s) {
|
||||||
|
return errors.Errorf("%s %q is not allowed", key, s)
|
||||||
|
}
|
||||||
|
case "hosts":
|
||||||
|
if isStr && hostsContainLocal(s) {
|
||||||
|
return errors.Errorf("hosts %q is not allowed", s)
|
||||||
|
}
|
||||||
|
case "local_action":
|
||||||
|
return errors.Errorf("local_action is not allowed")
|
||||||
|
case "ansible_user":
|
||||||
|
if isStr && s != "" && !IsValidAnsibleUser(s) {
|
||||||
|
return errors.Errorf("invalid ansible_user")
|
||||||
|
}
|
||||||
|
case "ansible_ssh_private_key_file":
|
||||||
|
if isStr {
|
||||||
|
if _, err := fileutils2.CleanRelSubpath(s); err != nil {
|
||||||
|
return errors.Wrap(err, "ansible_ssh_private_key_file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "import_playbook", "include", "include_playbook", "import_tasks", "include_tasks", "include_vars":
|
||||||
|
if isStr {
|
||||||
|
if strings.Contains(s, "://") || filepath.IsAbs(s) || strings.Contains(s, "{{") {
|
||||||
|
return errors.Errorf("%s %q is not allowed", key, s)
|
||||||
|
}
|
||||||
|
if _, err := fileutils2.CleanRelSubpath(s); err != nil {
|
||||||
|
return errors.Wrapf(err, "%s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isDeniedAnsibleVar(lk) {
|
||||||
|
return errors.Errorf("%s is not allowed", key)
|
||||||
|
}
|
||||||
|
if parent == "hosts" && isLocalTarget(key) {
|
||||||
|
return errors.Errorf("host %q is not allowed", key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostsContainLocal(s string) bool {
|
||||||
|
for _, part := range strings.Split(s, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
host := stripHostPort(part)
|
||||||
|
if isLocalTarget(host) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripHostPort(part string) string {
|
||||||
|
if strings.HasPrefix(part, "[") {
|
||||||
|
end := strings.Index(part, "]")
|
||||||
|
if end > 1 {
|
||||||
|
return part[1:end]
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(part, ":"); i > 0 {
|
||||||
|
if net.ParseIP(part) == nil {
|
||||||
|
return part[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
|
||||||
|
func yamlString(v interface{}) (string, bool) {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return t, true
|
||||||
|
case []byte:
|
||||||
|
return string(t), true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ import (
|
|||||||
|
|
||||||
"yunion.io/x/pkg/errors"
|
"yunion.io/x/pkg/errors"
|
||||||
yerrors "yunion.io/x/pkg/util/errors"
|
yerrors "yunion.io/x/pkg/util/errors"
|
||||||
|
|
||||||
|
"yunion.io/x/onecloud/pkg/util/fileutils2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IPlaybookSession interface {
|
type IPlaybookSession interface {
|
||||||
@@ -145,7 +147,11 @@ func (r runnable) Run(ctx context.Context) (err error) {
|
|||||||
|
|
||||||
// write out files
|
// write out files
|
||||||
for name, content := range r.GetFiles() {
|
for name, content := range r.GetFiles() {
|
||||||
path := filepath.Join(tmpdir, name)
|
path, err2 := fileutils2.JoinInside(tmpdir, name)
|
||||||
|
if err2 != nil {
|
||||||
|
err = errors.Wrapf(err2, "playbook file %s", name)
|
||||||
|
return
|
||||||
|
}
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
err = os.MkdirAll(dir, os.FileMode(0700))
|
err = os.MkdirAll(dir, os.FileMode(0700))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ package ansiblev2
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"yunion.io/x/onecloud/pkg/util/ansible"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPlaybookString(t *testing.T) {
|
func TestPlaybookString(t *testing.T) {
|
||||||
@@ -102,5 +104,21 @@ func TestPlaybookString(t *testing.T) {
|
|||||||
configureBlock.Name = "Configure wireguard networks"
|
configureBlock.Name = "Configure wireguard networks"
|
||||||
play.Tasks = append(play.Tasks, configureBlock)
|
play.Tasks = append(play.Tasks, configureBlock)
|
||||||
pb := NewPlaybook(play)
|
pb := NewPlaybook(play)
|
||||||
t.Logf("\n%s", pb.String())
|
yml := pb.String()
|
||||||
|
t.Logf("\n%s", yml)
|
||||||
|
if err := ansible.ValidatePlaybookYAML(yml); err != nil {
|
||||||
|
t.Fatalf("generated playbook rejected: %v\n%s", err, yml)
|
||||||
|
}
|
||||||
|
inv := NewInventory()
|
||||||
|
h := NewHost()
|
||||||
|
h.Vars = map[string]interface{}{
|
||||||
|
"ansible_user": "cloudroot",
|
||||||
|
"ansible_host": "10.1.2.3",
|
||||||
|
"ansible_ssh_private_key_file": ".id_rsa",
|
||||||
|
"ansible_become": "yes",
|
||||||
|
}
|
||||||
|
inv.SetHost("gw1", h)
|
||||||
|
if err := ansible.ValidateInventoryYAML(inv.String()); err != nil {
|
||||||
|
t.Fatalf("generated inventory rejected: %v\n%s", err, inv.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,11 +102,14 @@ func CleanRelSubpath(p string) (string, error) {
|
|||||||
if p == "" {
|
if p == "" {
|
||||||
return "", errors.Errorf("path is empty")
|
return "", errors.Errorf("path is empty")
|
||||||
}
|
}
|
||||||
|
if strings.ContainsRune(p, 0) {
|
||||||
|
return "", errors.Errorf("path contains NUL")
|
||||||
|
}
|
||||||
if filepath.IsAbs(p) {
|
if filepath.IsAbs(p) {
|
||||||
return "", errors.Errorf("path %q must be relative", p)
|
return "", errors.Errorf("path %q must be relative", p)
|
||||||
}
|
}
|
||||||
clean := filepath.Clean(p)
|
clean := filepath.Clean(p)
|
||||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||||
return "", errors.Errorf("path %q is not allowed", p)
|
return "", errors.Errorf("path %q is not allowed", p)
|
||||||
}
|
}
|
||||||
if filepath.IsAbs(clean) {
|
if filepath.IsAbs(clean) {
|
||||||
|
|||||||
@@ -90,7 +90,14 @@ func TestCleanRelSubpath(t *testing.T) {
|
|||||||
if got != "foo/bar" {
|
if got != "foo/bar" {
|
||||||
t.Fatalf("got %q", got)
|
t.Fatalf("got %q", got)
|
||||||
}
|
}
|
||||||
for _, p := range []string{"", "/abs", "../etc", "foo/../../etc", ".."} {
|
got, err = CleanRelSubpath(".id_rsa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dotfile: %v", err)
|
||||||
|
}
|
||||||
|
if got != ".id_rsa" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
for _, p := range []string{"", "/abs", "../etc", "foo/../../etc", "..", ".", "a\x00b"} {
|
||||||
if _, err := CleanRelSubpath(p); err == nil {
|
if _, err := CleanRelSubpath(p); err == nil {
|
||||||
t.Fatalf("expected error for %q", p)
|
t.Fatalf("expected error for %q", p)
|
||||||
}
|
}
|
||||||
@@ -111,6 +118,14 @@ func TestJoinInside(t *testing.T) {
|
|||||||
if _, err := JoinInside("/mnt/disk", "/etc"); err == nil {
|
if _, err := JoinInside("/mnt/disk", "/etc"); err == nil {
|
||||||
t.Fatal("expected absolute to fail")
|
t.Fatal("expected absolute to fail")
|
||||||
}
|
}
|
||||||
|
base := t.TempDir()
|
||||||
|
got, err = JoinInside(base, "a/b.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("temp base: %v", err)
|
||||||
|
}
|
||||||
|
if !IsPathInside(base, got) {
|
||||||
|
t.Fatalf("escaped: %q", got)
|
||||||
|
}
|
||||||
got, err = JoinInsideAll("/mnt/disk", "sub", "dir")
|
got, err = JoinInsideAll("/mnt/disk", "sub", "dir")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join all: %v", err)
|
t.Fatalf("join all: %v", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user