mirror of https://github.com/coder/coder.git
chore: change max share level on existing port shares (#12411)
This commit is contained in:
parent
5106d9fc47
commit
61bd341a36
|
@ -915,6 +915,19 @@ func (q *querier) DeleteWorkspaceAgentPortShare(ctx context.Context, arg databas
|
|||
return q.db.DeleteWorkspaceAgentPortShare(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteWorkspaceAgentPortSharesByTemplate(ctx context.Context, templateID uuid.UUID) error {
|
||||
template, err := q.db.GetTemplateByID(ctx, templateID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return q.db.DeleteWorkspaceAgentPortSharesByTemplate(ctx, templateID)
|
||||
}
|
||||
|
||||
func (q *querier) FavoriteWorkspace(ctx context.Context, id uuid.UUID) error {
|
||||
fetch := func(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
|
||||
return q.db.GetWorkspaceByID(ctx, id)
|
||||
|
@ -2614,6 +2627,19 @@ func (q *querier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID
|
|||
return q.db.ListWorkspaceAgentPortShares(ctx, workspaceID)
|
||||
}
|
||||
|
||||
func (q *querier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error {
|
||||
template, err := q.db.GetTemplateByID(ctx, templateID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return q.db.ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx, templateID)
|
||||
}
|
||||
|
||||
func (q *querier) RegisterWorkspaceProxy(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) {
|
||||
fetch := func(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) {
|
||||
return q.db.GetWorkspaceProxyByID(ctx, arg.ID)
|
||||
|
|
|
@ -1635,6 +1635,20 @@ func (s *MethodTestSuite) TestWorkspacePortSharing() {
|
|||
Port: ps.Port,
|
||||
}).Asserts(ws, rbac.ActionUpdate).Returns()
|
||||
}))
|
||||
s.Run("DeleteWorkspaceAgentPortSharesByTemplate", s.Subtest(func(db database.Store, check *expects) {
|
||||
u := dbgen.User(s.T(), db, database.User{})
|
||||
t := dbgen.Template(s.T(), db, database.Template{})
|
||||
ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID, TemplateID: t.ID})
|
||||
_ = dbgen.WorkspaceAgentPortShare(s.T(), db, database.WorkspaceAgentPortShare{WorkspaceID: ws.ID})
|
||||
check.Args(t.ID).Asserts(t, rbac.ActionUpdate).Returns()
|
||||
}))
|
||||
s.Run("ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate", s.Subtest(func(db database.Store, check *expects) {
|
||||
u := dbgen.User(s.T(), db, database.User{})
|
||||
t := dbgen.Template(s.T(), db, database.Template{})
|
||||
ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID, TemplateID: t.ID})
|
||||
_ = dbgen.WorkspaceAgentPortShare(s.T(), db, database.WorkspaceAgentPortShare{WorkspaceID: ws.ID})
|
||||
check.Args(t.ID).Asserts(t, rbac.ActionUpdate).Returns()
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *MethodTestSuite) TestExtraMethods() {
|
||||
|
|
|
@ -1455,6 +1455,30 @@ func (q *FakeQuerier) DeleteWorkspaceAgentPortShare(_ context.Context, arg datab
|
|||
return nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) DeleteWorkspaceAgentPortSharesByTemplate(_ context.Context, templateID uuid.UUID) error {
|
||||
err := validateDatabaseType(templateID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
for _, workspace := range q.workspaces {
|
||||
if workspace.TemplateID != templateID {
|
||||
continue
|
||||
}
|
||||
for i, share := range q.workspaceAgentPortShares {
|
||||
if share.WorkspaceID != workspace.ID {
|
||||
continue
|
||||
}
|
||||
q.workspaceAgentPortShares = append(q.workspaceAgentPortShares[:i], q.workspaceAgentPortShares[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) FavoriteWorkspace(_ context.Context, arg uuid.UUID) error {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
|
@ -6339,6 +6363,33 @@ func (q *FakeQuerier) ListWorkspaceAgentPortShares(_ context.Context, workspaceI
|
|||
return shares, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(_ context.Context, templateID uuid.UUID) error {
|
||||
err := validateDatabaseType(templateID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
for _, workspace := range q.workspaces {
|
||||
if workspace.TemplateID != templateID {
|
||||
continue
|
||||
}
|
||||
for i, share := range q.workspaceAgentPortShares {
|
||||
if share.WorkspaceID != workspace.ID {
|
||||
continue
|
||||
}
|
||||
if share.ShareLevel == database.AppSharingLevelPublic {
|
||||
share.ShareLevel = database.AppSharingLevelAuthenticated
|
||||
}
|
||||
q.workspaceAgentPortShares[i] = share
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) RegisterWorkspaceProxy(_ context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
|
|
@ -321,6 +321,13 @@ func (m metricsStore) DeleteWorkspaceAgentPortShare(ctx context.Context, arg dat
|
|||
return r0
|
||||
}
|
||||
|
||||
func (m metricsStore) DeleteWorkspaceAgentPortSharesByTemplate(ctx context.Context, templateID uuid.UUID) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.DeleteWorkspaceAgentPortSharesByTemplate(ctx, templateID)
|
||||
m.queryLatencies.WithLabelValues("DeleteWorkspaceAgentPortSharesByTemplate").Observe(time.Since(start).Seconds())
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m metricsStore) FavoriteWorkspace(ctx context.Context, arg uuid.UUID) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.FavoriteWorkspace(ctx, arg)
|
||||
|
@ -1698,6 +1705,13 @@ func (m metricsStore) ListWorkspaceAgentPortShares(ctx context.Context, workspac
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
func (m metricsStore) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx, templateID)
|
||||
m.queryLatencies.WithLabelValues("ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate").Observe(time.Since(start).Seconds())
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m metricsStore) RegisterWorkspaceProxy(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) {
|
||||
start := time.Now()
|
||||
proxy, err := m.s.RegisterWorkspaceProxy(ctx, arg)
|
||||
|
|
|
@ -542,6 +542,20 @@ func (mr *MockStoreMockRecorder) DeleteWorkspaceAgentPortShare(arg0, arg1 any) *
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceAgentPortShare", reflect.TypeOf((*MockStore)(nil).DeleteWorkspaceAgentPortShare), arg0, arg1)
|
||||
}
|
||||
|
||||
// DeleteWorkspaceAgentPortSharesByTemplate mocks base method.
|
||||
func (m *MockStore) DeleteWorkspaceAgentPortSharesByTemplate(arg0 context.Context, arg1 uuid.UUID) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteWorkspaceAgentPortSharesByTemplate", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteWorkspaceAgentPortSharesByTemplate indicates an expected call of DeleteWorkspaceAgentPortSharesByTemplate.
|
||||
func (mr *MockStoreMockRecorder) DeleteWorkspaceAgentPortSharesByTemplate(arg0, arg1 any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceAgentPortSharesByTemplate", reflect.TypeOf((*MockStore)(nil).DeleteWorkspaceAgentPortSharesByTemplate), arg0, arg1)
|
||||
}
|
||||
|
||||
// FavoriteWorkspace mocks base method.
|
||||
func (m *MockStore) FavoriteWorkspace(arg0 context.Context, arg1 uuid.UUID) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -3588,6 +3602,20 @@ func (mr *MockStoreMockRecorder) Ping(arg0 any) *gomock.Call {
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockStore)(nil).Ping), arg0)
|
||||
}
|
||||
|
||||
// ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate mocks base method.
|
||||
func (m *MockStore) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(arg0 context.Context, arg1 uuid.UUID) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate indicates an expected call of ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate.
|
||||
func (mr *MockStoreMockRecorder) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(arg0, arg1 any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate", reflect.TypeOf((*MockStore)(nil).ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate), arg0, arg1)
|
||||
}
|
||||
|
||||
// RegisterWorkspaceProxy mocks base method.
|
||||
func (m *MockStore) RegisterWorkspaceProxy(arg0 context.Context, arg1 database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
|
|
@ -80,6 +80,7 @@ type sqlcQuerier interface {
|
|||
DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error)
|
||||
DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error)
|
||||
DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error
|
||||
DeleteWorkspaceAgentPortSharesByTemplate(ctx context.Context, templateID uuid.UUID) error
|
||||
FavoriteWorkspace(ctx context.Context, id uuid.UUID) error
|
||||
GetAPIKeyByID(ctx context.Context, id string) (APIKey, error)
|
||||
// there is no unique constraint on empty token names
|
||||
|
@ -330,6 +331,7 @@ type sqlcQuerier interface {
|
|||
InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error)
|
||||
InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error)
|
||||
ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error)
|
||||
ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error
|
||||
RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error)
|
||||
RemoveUserFromAllGroups(ctx context.Context, userID uuid.UUID) error
|
||||
RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error
|
||||
|
|
|
@ -8483,6 +8483,15 @@ func (q *sqlQuerier) DeleteWorkspaceAgentPortShare(ctx context.Context, arg Dele
|
|||
return err
|
||||
}
|
||||
|
||||
const deleteWorkspaceAgentPortSharesByTemplate = `-- name: DeleteWorkspaceAgentPortSharesByTemplate :exec
|
||||
DELETE FROM workspace_agent_port_share WHERE workspace_id IN (SELECT id FROM workspaces WHERE template_id = $1)
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) DeleteWorkspaceAgentPortSharesByTemplate(ctx context.Context, templateID uuid.UUID) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteWorkspaceAgentPortSharesByTemplate, templateID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getWorkspaceAgentPortShare = `-- name: GetWorkspaceAgentPortShare :one
|
||||
SELECT workspace_id, agent_name, port, share_level FROM workspace_agent_port_share WHERE workspace_id = $1 AND agent_name = $2 AND port = $3
|
||||
`
|
||||
|
@ -8537,6 +8546,15 @@ func (q *sqlQuerier) ListWorkspaceAgentPortShares(ctx context.Context, workspace
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const reduceWorkspaceAgentShareLevelToAuthenticatedByTemplate = `-- name: ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate :exec
|
||||
UPDATE workspace_agent_port_share SET share_level = 'authenticated' WHERE share_level = 'public' AND workspace_id IN (SELECT id FROM workspaces WHERE template_id = $1)
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error {
|
||||
_, err := q.db.ExecContext(ctx, reduceWorkspaceAgentShareLevelToAuthenticatedByTemplate, templateID)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertWorkspaceAgentPortShare = `-- name: UpsertWorkspaceAgentPortShare :one
|
||||
INSERT INTO workspace_agent_port_share (workspace_id, agent_name, port, share_level)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
|
|
|
@ -11,3 +11,9 @@ DELETE FROM workspace_agent_port_share WHERE workspace_id = $1 AND agent_name =
|
|||
INSERT INTO workspace_agent_port_share (workspace_id, agent_name, port, share_level)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (workspace_id, agent_name, port) DO UPDATE SET share_level = $4 RETURNING *;
|
||||
|
||||
-- name: ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate :exec
|
||||
UPDATE workspace_agent_port_share SET share_level = 'authenticated' WHERE share_level = 'public' AND workspace_id IN (SELECT id FROM workspaces WHERE template_id = $1);
|
||||
|
||||
-- name: DeleteWorkspaceAgentPortSharesByTemplate :exec
|
||||
DELETE FROM workspace_agent_port_share WHERE workspace_id IN (SELECT id FROM workspaces WHERE template_id = $1);
|
||||
|
|
|
@ -698,6 +698,21 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|||
delete(groupACL, template.OrganizationID.String())
|
||||
}
|
||||
|
||||
if template.MaxPortSharingLevel != maxPortShareLevel {
|
||||
switch maxPortShareLevel {
|
||||
case database.AppSharingLevelOwner:
|
||||
err = tx.DeleteWorkspaceAgentPortSharesByTemplate(ctx, template.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete workspace agent port shares by template: %w", err)
|
||||
}
|
||||
case database.AppSharingLevelAuthenticated:
|
||||
err = tx.ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx, template.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reduce workspace agent share level to authenticated by template: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
err = tx.UpdateTemplateMetaByID(ctx, database.UpdateTemplateMetaByIDParams{
|
||||
ID: template.ID,
|
||||
|
|
|
@ -22,6 +22,7 @@ import (
|
|||
"github.com/coder/coder/v2/enterprise/coderd/license"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/schedule"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
|
@ -143,9 +144,12 @@ func TestTemplates(t *testing.T) {
|
|||
t.Run("MaxPortShareLevel", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := coderdtest.DeploymentValues(t)
|
||||
cfg.Experiments = []string{"shared-ports"}
|
||||
owner, user := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
DeploymentValues: cfg,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
|
@ -154,9 +158,43 @@ func TestTemplates(t *testing.T) {
|
|||
},
|
||||
})
|
||||
client, _ := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.PlanComplete,
|
||||
ProvisionApply: []*proto.Response{{
|
||||
Type: &proto.Response_Log{
|
||||
Log: &proto.Log{
|
||||
Level: proto.LogLevel_INFO,
|
||||
Output: "example",
|
||||
},
|
||||
},
|
||||
}, {
|
||||
Type: &proto.Response_Apply{
|
||||
Apply: &proto.ApplyComplete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "some",
|
||||
Type: "example",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: "something",
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: uuid.NewString(),
|
||||
},
|
||||
Name: "test",
|
||||
}},
|
||||
}, {
|
||||
Name: "another",
|
||||
Type: "example",
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||
ws, err := client.Workspace(context.Background(), ws.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
@ -175,6 +213,39 @@ func TestTemplates(t *testing.T) {
|
|||
MaxPortShareLevel: &level,
|
||||
})
|
||||
require.ErrorContains(t, err, "invalid max port sharing level")
|
||||
|
||||
// Create public port share
|
||||
_, err = client.UpsertWorkspaceAgentPortShare(ctx, ws.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{
|
||||
AgentName: ws.LatestBuild.Resources[0].Agents[0].Name,
|
||||
Port: 8080,
|
||||
ShareLevel: codersdk.WorkspaceAgentPortShareLevelPublic,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Reduce max level to authenticated
|
||||
level = codersdk.WorkspaceAgentPortShareLevelAuthenticated
|
||||
_, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
|
||||
MaxPortShareLevel: &level,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ensure previously public port is now authenticated
|
||||
wpsr, err := client.GetWorkspaceAgentPortShares(ctx, ws.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, wpsr.Shares, 1)
|
||||
assert.Equal(t, codersdk.WorkspaceAgentPortShareLevelAuthenticated, wpsr.Shares[0].ShareLevel)
|
||||
|
||||
// reduce max level to owner
|
||||
level = codersdk.WorkspaceAgentPortShareLevelOwner
|
||||
_, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
|
||||
MaxPortShareLevel: &level,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ensure previously authenticated port is removed
|
||||
wpsr, err = client.GetWorkspaceAgentPortShares(ctx, ws.ID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, wpsr.Shares)
|
||||
})
|
||||
|
||||
t.Run("BlockDisablingAutoOffWithMaxTTL", func(t *testing.T) {
|
||||
|
|
Loading…
Reference in New Issue