mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
chore: Refactor Enterprise code to layer on top of AGPL (#4034)
* chore: Refactor Enterprise code to layer on top of AGPL This is an experiment to invert the import order of the Enterprise code to layer on top of AGPL. * Fix Garrett's comments * Add pointer.Handle to atomically obtain references This uses a context to ensure the same value persists through multiple executions to `Load()`. * Remove entitlements API from AGPL coderd * Remove AGPL Coder entitlements endpoint test * Fix warnings output * Add command-line flag to toggle audit logging * Fix hasLicense being set * Remove features interface * Fix audit logging default * Add bash as a dependency * Add comment * Add tests for resync and pubsub, and add back previous exp backoff retry * Separate authz code again * Add pointer loading example from comment * Fix duplicate test, remove pointer.Handle * Fix expired license * Add entitlements struct * Fix context passing
This commit is contained in:
@ -22,143 +22,6 @@ import (
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
type RouteCheck struct {
|
||||
NoAuthorize bool
|
||||
AssertAction rbac.Action
|
||||
AssertObject rbac.Object
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
type AuthTester struct {
|
||||
t *testing.T
|
||||
api *coderd.API
|
||||
authorizer *recordingAuthorizer
|
||||
|
||||
Client *codersdk.Client
|
||||
Workspace codersdk.Workspace
|
||||
Organization codersdk.Organization
|
||||
Admin codersdk.CreateFirstUserResponse
|
||||
Template codersdk.Template
|
||||
Version codersdk.TemplateVersion
|
||||
WorkspaceResource codersdk.WorkspaceResource
|
||||
File codersdk.UploadResponse
|
||||
TemplateVersionDryRun codersdk.ProvisionerJob
|
||||
TemplateParam codersdk.Parameter
|
||||
URLParams map[string]string
|
||||
}
|
||||
|
||||
func NewAuthTester(ctx context.Context, t *testing.T, options *Options) *AuthTester {
|
||||
authorizer := &recordingAuthorizer{}
|
||||
if options == nil {
|
||||
options = &Options{}
|
||||
}
|
||||
if options.Authorizer != nil {
|
||||
t.Error("NewAuthTester cannot be called with custom Authorizer")
|
||||
}
|
||||
options.Authorizer = authorizer
|
||||
options.IncludeProvisionerDaemon = true
|
||||
|
||||
client, _, api := newWithAPI(t, options)
|
||||
admin := CreateFirstUser(t, client)
|
||||
// The provisioner will call to coderd and register itself. This is async,
|
||||
// so we wait for it to occur.
|
||||
require.Eventually(t, func() bool {
|
||||
provisionerds, err := client.ProvisionerDaemons(ctx)
|
||||
return assert.NoError(t, err) && len(provisionerds) > 0
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
|
||||
provisionerds, err := client.ProvisionerDaemons(ctx)
|
||||
require.NoError(t, err, "fetch provisioners")
|
||||
require.Len(t, provisionerds, 1)
|
||||
|
||||
organization, err := client.Organization(ctx, admin.OrganizationID)
|
||||
require.NoError(t, err, "fetch org")
|
||||
|
||||
// Setup some data in the database.
|
||||
version := CreateTemplateVersion(t, client, admin.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
// Return a workspace resource
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "some",
|
||||
Type: "example",
|
||||
Agents: []*proto.Agent{{
|
||||
Name: "agent",
|
||||
Id: "something",
|
||||
Auth: &proto.Agent_Token{},
|
||||
Apps: []*proto.App{{
|
||||
Name: "testapp",
|
||||
Url: "http://localhost:3000",
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := CreateTemplate(t, client, admin.OrganizationID, version.ID)
|
||||
workspace := CreateWorkspace(t, client, admin.OrganizationID, template.ID)
|
||||
AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
file, err := client.Upload(ctx, codersdk.ContentTypeTar, make([]byte, 1024))
|
||||
require.NoError(t, err, "upload file")
|
||||
workspaceResources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
require.NoError(t, err, "workspace resources")
|
||||
templateVersionDryRun, err := client.CreateTemplateVersionDryRun(ctx, version.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
ParameterValues: []codersdk.CreateParameterRequest{},
|
||||
})
|
||||
require.NoError(t, err, "template version dry-run")
|
||||
|
||||
templateParam, err := client.CreateParameter(ctx, codersdk.ParameterTemplate, template.ID, codersdk.CreateParameterRequest{
|
||||
Name: "test-param",
|
||||
SourceValue: "hello world",
|
||||
SourceScheme: codersdk.ParameterSourceSchemeData,
|
||||
DestinationScheme: codersdk.ParameterDestinationSchemeProvisionerVariable,
|
||||
})
|
||||
require.NoError(t, err, "create template param")
|
||||
|
||||
urlParameters := map[string]string{
|
||||
"{organization}": admin.OrganizationID.String(),
|
||||
"{user}": admin.UserID.String(),
|
||||
"{organizationname}": organization.Name,
|
||||
"{workspace}": workspace.ID.String(),
|
||||
"{workspacebuild}": workspace.LatestBuild.ID.String(),
|
||||
"{workspacename}": workspace.Name,
|
||||
"{workspaceagent}": workspaceResources[0].Agents[0].ID.String(),
|
||||
"{buildnumber}": strconv.FormatInt(int64(workspace.LatestBuild.BuildNumber), 10),
|
||||
"{template}": template.ID.String(),
|
||||
"{hash}": file.Hash,
|
||||
"{workspaceresource}": workspaceResources[0].ID.String(),
|
||||
"{workspaceapp}": workspaceResources[0].Agents[0].Apps[0].Name,
|
||||
"{templateversion}": version.ID.String(),
|
||||
"{jobID}": templateVersionDryRun.ID.String(),
|
||||
"{templatename}": template.Name,
|
||||
"{workspace_and_agent}": workspace.Name + "." + workspaceResources[0].Agents[0].Name,
|
||||
// Only checking template scoped params here
|
||||
"parameters/{scope}/{id}": fmt.Sprintf("parameters/%s/%s",
|
||||
string(templateParam.Scope), templateParam.ScopeID.String()),
|
||||
}
|
||||
|
||||
return &AuthTester{
|
||||
t: t,
|
||||
api: api,
|
||||
authorizer: authorizer,
|
||||
Client: client,
|
||||
Workspace: workspace,
|
||||
Organization: organization,
|
||||
Admin: admin,
|
||||
Template: template,
|
||||
Version: version,
|
||||
WorkspaceResource: workspaceResources[0],
|
||||
File: file,
|
||||
TemplateVersionDryRun: templateVersionDryRun,
|
||||
TemplateParam: templateParam,
|
||||
URLParams: urlParameters,
|
||||
}
|
||||
}
|
||||
|
||||
func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
|
||||
// Some quick reused objects
|
||||
workspaceRBACObj := rbac.ResourceWorkspace.InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String())
|
||||
@ -181,7 +44,6 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
|
||||
"POST:/api/v2/users/login": {NoAuthorize: true},
|
||||
"GET:/api/v2/users/authmethods": {NoAuthorize: true},
|
||||
"POST:/api/v2/csp/reports": {NoAuthorize: true},
|
||||
"GET:/api/v2/entitlements": {NoAuthorize: true},
|
||||
|
||||
// Has it's own auth
|
||||
"GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true},
|
||||
@ -408,6 +270,134 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
|
||||
return skipRoutes, assertRoute
|
||||
}
|
||||
|
||||
type RouteCheck struct {
|
||||
NoAuthorize bool
|
||||
AssertAction rbac.Action
|
||||
AssertObject rbac.Object
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
type AuthTester struct {
|
||||
t *testing.T
|
||||
api *coderd.API
|
||||
authorizer *RecordingAuthorizer
|
||||
|
||||
Client *codersdk.Client
|
||||
Workspace codersdk.Workspace
|
||||
Organization codersdk.Organization
|
||||
Admin codersdk.CreateFirstUserResponse
|
||||
Template codersdk.Template
|
||||
Version codersdk.TemplateVersion
|
||||
WorkspaceResource codersdk.WorkspaceResource
|
||||
File codersdk.UploadResponse
|
||||
TemplateVersionDryRun codersdk.ProvisionerJob
|
||||
TemplateParam codersdk.Parameter
|
||||
URLParams map[string]string
|
||||
}
|
||||
|
||||
func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, api *coderd.API, admin codersdk.CreateFirstUserResponse) *AuthTester {
|
||||
authorizer, ok := api.Authorizer.(*RecordingAuthorizer)
|
||||
if !ok {
|
||||
t.Fail()
|
||||
}
|
||||
// The provisioner will call to coderd and register itself. This is async,
|
||||
// so we wait for it to occur.
|
||||
require.Eventually(t, func() bool {
|
||||
provisionerds, err := client.ProvisionerDaemons(ctx)
|
||||
return assert.NoError(t, err) && len(provisionerds) > 0
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
|
||||
provisionerds, err := client.ProvisionerDaemons(ctx)
|
||||
require.NoError(t, err, "fetch provisioners")
|
||||
require.Len(t, provisionerds, 1)
|
||||
|
||||
organization, err := client.Organization(ctx, admin.OrganizationID)
|
||||
require.NoError(t, err, "fetch org")
|
||||
|
||||
// Setup some data in the database.
|
||||
version := CreateTemplateVersion(t, client, admin.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
// Return a workspace resource
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "some",
|
||||
Type: "example",
|
||||
Agents: []*proto.Agent{{
|
||||
Name: "agent",
|
||||
Id: "something",
|
||||
Auth: &proto.Agent_Token{},
|
||||
Apps: []*proto.App{{
|
||||
Name: "testapp",
|
||||
Url: "http://localhost:3000",
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := CreateTemplate(t, client, admin.OrganizationID, version.ID)
|
||||
workspace := CreateWorkspace(t, client, admin.OrganizationID, template.ID)
|
||||
AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
file, err := client.Upload(ctx, codersdk.ContentTypeTar, make([]byte, 1024))
|
||||
require.NoError(t, err, "upload file")
|
||||
workspaceResources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
require.NoError(t, err, "workspace resources")
|
||||
templateVersionDryRun, err := client.CreateTemplateVersionDryRun(ctx, version.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
ParameterValues: []codersdk.CreateParameterRequest{},
|
||||
})
|
||||
require.NoError(t, err, "template version dry-run")
|
||||
|
||||
templateParam, err := client.CreateParameter(ctx, codersdk.ParameterTemplate, template.ID, codersdk.CreateParameterRequest{
|
||||
Name: "test-param",
|
||||
SourceValue: "hello world",
|
||||
SourceScheme: codersdk.ParameterSourceSchemeData,
|
||||
DestinationScheme: codersdk.ParameterDestinationSchemeProvisionerVariable,
|
||||
})
|
||||
require.NoError(t, err, "create template param")
|
||||
urlParameters := map[string]string{
|
||||
"{organization}": admin.OrganizationID.String(),
|
||||
"{user}": admin.UserID.String(),
|
||||
"{organizationname}": organization.Name,
|
||||
"{workspace}": workspace.ID.String(),
|
||||
"{workspacebuild}": workspace.LatestBuild.ID.String(),
|
||||
"{workspacename}": workspace.Name,
|
||||
"{workspaceagent}": workspaceResources[0].Agents[0].ID.String(),
|
||||
"{buildnumber}": strconv.FormatInt(int64(workspace.LatestBuild.BuildNumber), 10),
|
||||
"{template}": template.ID.String(),
|
||||
"{hash}": file.Hash,
|
||||
"{workspaceresource}": workspaceResources[0].ID.String(),
|
||||
"{workspaceapp}": workspaceResources[0].Agents[0].Apps[0].Name,
|
||||
"{templateversion}": version.ID.String(),
|
||||
"{jobID}": templateVersionDryRun.ID.String(),
|
||||
"{templatename}": template.Name,
|
||||
"{workspace_and_agent}": workspace.Name + "." + workspaceResources[0].Agents[0].Name,
|
||||
// Only checking template scoped params here
|
||||
"parameters/{scope}/{id}": fmt.Sprintf("parameters/%s/%s",
|
||||
string(templateParam.Scope), templateParam.ScopeID.String()),
|
||||
}
|
||||
|
||||
return &AuthTester{
|
||||
t: t,
|
||||
api: api,
|
||||
authorizer: authorizer,
|
||||
Client: client,
|
||||
Workspace: workspace,
|
||||
Organization: organization,
|
||||
Admin: admin,
|
||||
Template: template,
|
||||
Version: version,
|
||||
WorkspaceResource: workspaceResources[0],
|
||||
File: file,
|
||||
TemplateVersionDryRun: templateVersionDryRun,
|
||||
TemplateParam: templateParam,
|
||||
URLParams: urlParameters,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck, skipRoutes map[string]string) {
|
||||
// Always fail auth from this point forward
|
||||
a.authorizer.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil)
|
||||
@ -433,7 +423,7 @@ func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck
|
||||
}
|
||||
|
||||
err := chi.Walk(
|
||||
a.api.Handler,
|
||||
a.api.RootHandler,
|
||||
func(
|
||||
method string,
|
||||
route string,
|
||||
@ -513,14 +503,14 @@ type authCall struct {
|
||||
Object rbac.Object
|
||||
}
|
||||
|
||||
type recordingAuthorizer struct {
|
||||
type RecordingAuthorizer struct {
|
||||
Called *authCall
|
||||
AlwaysReturn error
|
||||
}
|
||||
|
||||
var _ rbac.Authorizer = (*recordingAuthorizer)(nil)
|
||||
var _ rbac.Authorizer = (*RecordingAuthorizer)(nil)
|
||||
|
||||
func (r *recordingAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, scope rbac.Scope, action rbac.Action, object rbac.Object) error {
|
||||
func (r *RecordingAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, scope rbac.Scope, action rbac.Action, object rbac.Object) error {
|
||||
r.Called = &authCall{
|
||||
SubjectID: subjectID,
|
||||
Roles: roleNames,
|
||||
@ -531,7 +521,7 @@ func (r *recordingAuthorizer) ByRoleName(_ context.Context, subjectID string, ro
|
||||
return r.AlwaysReturn
|
||||
}
|
||||
|
||||
func (r *recordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID string, roles []string, scope rbac.Scope, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) {
|
||||
func (r *RecordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID string, roles []string, scope rbac.Scope, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) {
|
||||
return &fakePreparedAuthorizer{
|
||||
Original: r,
|
||||
SubjectID: subjectID,
|
||||
@ -541,12 +531,12 @@ func (r *recordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID str
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *recordingAuthorizer) reset() {
|
||||
func (r *RecordingAuthorizer) reset() {
|
||||
r.Called = nil
|
||||
}
|
||||
|
||||
type fakePreparedAuthorizer struct {
|
||||
Original *recordingAuthorizer
|
||||
Original *RecordingAuthorizer
|
||||
SubjectID string
|
||||
Roles []string
|
||||
Scope rbac.Scope
|
20
coderd/coderdtest/authorize_test.go
Normal file
20
coderd/coderdtest/authorize_test.go
Normal file
@ -0,0 +1,20 @@
|
||||
package coderdtest_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
)
|
||||
|
||||
func TestAuthorizeAllEndpoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
Authorizer: &coderdtest.RecordingAuthorizer{},
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
a := coderdtest.NewAuthTester(context.Background(), t, client, api, admin)
|
||||
skipRoute, assertRoute := coderdtest.AGPLRoutes(a)
|
||||
a.Test(context.Background(), assertRoute, skipRoute)
|
||||
}
|
@ -80,7 +80,6 @@ type Options struct {
|
||||
|
||||
// IncludeProvisionerDaemon when true means to start an in-memory provisionerD
|
||||
IncludeProvisionerDaemon bool
|
||||
APIBuilder func(*coderd.Options) *coderd.API
|
||||
MetricsCacheRefreshInterval time.Duration
|
||||
AgentStatsRefreshInterval time.Duration
|
||||
}
|
||||
@ -112,14 +111,11 @@ func NewWithProvisionerCloser(t *testing.T, options *Options) (*codersdk.Client,
|
||||
// and is a temporary measure while the API to register provisioners is ironed
|
||||
// out.
|
||||
func newWithCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer) {
|
||||
client, closer, _ := newWithAPI(t, options)
|
||||
client, closer, _ := NewWithAPI(t, options)
|
||||
return client, closer
|
||||
}
|
||||
|
||||
// newWithAPI constructs an in-memory API instance and returns a client to talk to it.
|
||||
// Most tests never need a reference to the API, but AuthorizationTest in this module uses it.
|
||||
// Do not expose the API or wrath shall descend upon thee.
|
||||
func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *coderd.API) {
|
||||
func NewOptions(t *testing.T, options *Options) (*httptest.Server, context.CancelFunc, *coderd.Options) {
|
||||
if options == nil {
|
||||
options = &Options{}
|
||||
}
|
||||
@ -140,9 +136,6 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
|
||||
close(options.AutobuildStats)
|
||||
})
|
||||
}
|
||||
if options.APIBuilder == nil {
|
||||
options.APIBuilder = coderd.New
|
||||
}
|
||||
|
||||
// This can be hotswapped for a live database instance.
|
||||
db := databasefake.New()
|
||||
@ -166,8 +159,6 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
|
||||
}
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer t.Cleanup(cancelFunc) // Defer to ensure cancelFunc is executed first.
|
||||
|
||||
lifecycleExecutor := executor.New(
|
||||
ctx,
|
||||
db,
|
||||
@ -201,13 +192,7 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
|
||||
options.SSHKeygenAlgorithm = gitsshkey.AlgorithmEd25519
|
||||
}
|
||||
|
||||
features := coderd.DisabledImplementations
|
||||
if options.Auditor != nil {
|
||||
features.Auditor = options.Auditor
|
||||
}
|
||||
|
||||
// We set the handler after server creation for the access URL.
|
||||
coderAPI := options.APIBuilder(&coderd.Options{
|
||||
return srv, cancelFunc, &coderd.Options{
|
||||
AgentConnectionUpdateFrequency: 150 * time.Millisecond,
|
||||
// Force a long disconnection timeout to ensure
|
||||
// agents are not marked as disconnected during slow tests.
|
||||
@ -218,6 +203,7 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
|
||||
Auditor: options.Auditor,
|
||||
AWSCertificates: options.AWSCertificates,
|
||||
AzureCertificates: options.AzureCertificates,
|
||||
GithubOAuth2Config: options.GithubOAuth2Config,
|
||||
@ -248,22 +234,30 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
|
||||
AutoImportTemplates: options.AutoImportTemplates,
|
||||
MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval,
|
||||
AgentStatsRefreshInterval: options.AgentStatsRefreshInterval,
|
||||
FeaturesService: coderd.NewMockFeaturesService(features),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = coderAPI.Close()
|
||||
})
|
||||
srv.Config.Handler = coderAPI.Handler
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithAPI constructs an in-memory API instance and returns a client to talk to it.
|
||||
// Most tests never need a reference to the API, but AuthorizationTest in this module uses it.
|
||||
// Do not expose the API or wrath shall descend upon thee.
|
||||
func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *coderd.API) {
|
||||
if options == nil {
|
||||
options = &Options{}
|
||||
}
|
||||
srv, cancelFunc, newOptions := NewOptions(t, options)
|
||||
// We set the handler after server creation for the access URL.
|
||||
coderAPI := coderd.New(newOptions)
|
||||
srv.Config.Handler = coderAPI.RootHandler
|
||||
var provisionerCloser io.Closer = nopcloser{}
|
||||
if options.IncludeProvisionerDaemon {
|
||||
provisionerCloser = NewProvisionerDaemon(t, coderAPI)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancelFunc()
|
||||
_ = provisionerCloser.Close()
|
||||
_ = coderAPI.Close()
|
||||
})
|
||||
|
||||
return codersdk.New(serverURL), provisionerCloser, coderAPI
|
||||
return codersdk.New(coderAPI.AccessURL), provisionerCloser, coderAPI
|
||||
}
|
||||
|
||||
// NewProvisionerDaemon launches a provisionerd instance configured to work
|
||||
|
Reference in New Issue
Block a user