K8s/FeatureFlags: Add an apiserver to manage feature flags (dev only) (#80501)
* add deployment registry API cloud only * update versions * add feature flag endpoints * use helpers * merge main * update AllowSelfServie and re-run code gen * fix package name * add allowselfserve flag to payload * remove config * update list api to return the full registry including states * change enabled check * fix compile error * add feature toggle and split path in frontend * changes * with status * add more status/state * add back config thing * add back config thing * merge main * merge main * now on the /current api endpoint * now on the /current api endpoint * drop frontend changes * change group name to featuretoggle (singular) * use the same settings * now with patch * more common refs * more common refs * WIP actually do the webhook * fix comment * fewer imports * registe standalone * one less file * fix singular name --------- Co-authored-by: Michael Mandrus <michael.mandrus@grafana.com>
This commit is contained in:
co-authored by
Michael Mandrus
parent
cbc84a802d
commit
41e523bde7
@@ -0,0 +1,112 @@
|
||||
package featuretoggle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureManager) v0alpha1.ResolvedToggleState {
|
||||
state := v0alpha1.ResolvedToggleState{
|
||||
TypeMeta: v1.TypeMeta{
|
||||
APIVersion: v0alpha1.APIVERSION,
|
||||
Kind: "ResolvedToggleState",
|
||||
},
|
||||
Enabled: features.GetEnabled(ctx),
|
||||
}
|
||||
|
||||
// Reference to the object that defined the values
|
||||
startupRef := &common.ObjectReference{
|
||||
Namespace: "system",
|
||||
Name: "startup",
|
||||
}
|
||||
|
||||
startup := features.GetStartupFlags()
|
||||
warnings := features.GetWarning()
|
||||
for _, f := range features.GetFlags() {
|
||||
name := f.Name
|
||||
if features.IsHiddenFromAdminPage(name, true) {
|
||||
continue
|
||||
}
|
||||
|
||||
toggle := v0alpha1.ToggleStatus{
|
||||
Name: name,
|
||||
Description: f.Description, // simplify the UI changes
|
||||
Enabled: state.Enabled[name],
|
||||
Writeable: features.IsEditableFromAdminPage(name),
|
||||
Source: startupRef,
|
||||
Warning: warnings[name],
|
||||
}
|
||||
if f.Expression == "true" && toggle.Enabled {
|
||||
toggle.Source = nil
|
||||
}
|
||||
_, inStartup := startup[name]
|
||||
if toggle.Enabled || toggle.Writeable || toggle.Warning != "" || inStartup {
|
||||
state.Toggles = append(state.Toggles, toggle)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPatch {
|
||||
b.handlePatchCurrent(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
state := getResolvedToggleState(r.Context(), b.features)
|
||||
|
||||
err := json.NewEncoder(w).Encode(state)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: authz is already handled by the authorizer
|
||||
func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *http.Request) {
|
||||
if !b.features.IsFeatureEditingAllowed() {
|
||||
_, _ = w.Write([]byte("Feature editing is disabled"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
request := v0alpha1.ResolvedToggleState{}
|
||||
err := web.Bind(r, &request)
|
||||
if err != nil {
|
||||
_, _ = w.Write([]byte("ERROR!!! " + err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if len(request.Toggles) > 0 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("can only patch the enabled"))
|
||||
return
|
||||
}
|
||||
|
||||
changes := map[string]bool{}
|
||||
for k, v := range request.Enabled {
|
||||
current := b.features.IsEnabled(ctx, k)
|
||||
if current != v {
|
||||
if !b.features.IsEditableFromAdminPage(k) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("can not edit toggle: " + k))
|
||||
return
|
||||
}
|
||||
changes[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if len(changes) == 0 {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte("TODO... actually UPDATE/call webhook: "))
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package featuretoggle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
_ rest.Storage = (*featuresStorage)(nil)
|
||||
_ rest.Scoper = (*featuresStorage)(nil)
|
||||
_ rest.SingularNameProvider = (*featuresStorage)(nil)
|
||||
_ rest.Lister = (*featuresStorage)(nil)
|
||||
_ rest.Getter = (*featuresStorage)(nil)
|
||||
)
|
||||
|
||||
type featuresStorage struct {
|
||||
resource *common.ResourceInfo
|
||||
tableConverter rest.TableConvertor
|
||||
features []featuremgmt.FeatureFlag
|
||||
startup int64
|
||||
}
|
||||
|
||||
// NOTE! this does not depend on config or any system state!
|
||||
// In the future, the existence of features (and their properties) can be defined dynamically
|
||||
func NewFeaturesStorage(features []featuremgmt.FeatureFlag) *featuresStorage {
|
||||
resourceInfo := v0alpha1.FeatureResourceInfo
|
||||
return &featuresStorage{
|
||||
startup: time.Now().UnixMilli(),
|
||||
resource: &resourceInfo,
|
||||
features: features,
|
||||
tableConverter: utils.NewTableConverter(
|
||||
resourceInfo.GroupResource(),
|
||||
[]metav1.TableColumnDefinition{
|
||||
{Name: "Name", Type: "string", Format: "name"},
|
||||
{Name: "Stage", Type: "string", Format: "string", Description: "Where is the flag in the dev cycle"},
|
||||
{Name: "Owner", Type: "string", Format: "string", Description: "Which team owns the feature"},
|
||||
},
|
||||
func(obj any) ([]interface{}, error) {
|
||||
r, ok := obj.(*v0alpha1.Feature)
|
||||
if ok {
|
||||
return []interface{}{
|
||||
r.Name,
|
||||
r.Spec.Stage,
|
||||
r.Spec.Owner,
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("expected resource or info")
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *featuresStorage) New() runtime.Object {
|
||||
return s.resource.NewFunc()
|
||||
}
|
||||
|
||||
func (s *featuresStorage) Destroy() {}
|
||||
|
||||
func (s *featuresStorage) NamespaceScoped() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *featuresStorage) GetSingularName() string {
|
||||
return s.resource.GetSingularName()
|
||||
}
|
||||
|
||||
func (s *featuresStorage) NewList() runtime.Object {
|
||||
return s.resource.NewListFunc()
|
||||
}
|
||||
|
||||
func (s *featuresStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
func (s *featuresStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
flags := &v0alpha1.FeatureList{
|
||||
ListMeta: metav1.ListMeta{
|
||||
ResourceVersion: fmt.Sprintf("%d", s.startup),
|
||||
},
|
||||
}
|
||||
for _, flag := range s.features {
|
||||
flags.Items = append(flags.Items, toK8sForm(flag))
|
||||
}
|
||||
return flags, nil
|
||||
}
|
||||
|
||||
func (s *featuresStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
for _, flag := range s.features {
|
||||
if name == flag.Name {
|
||||
obj := toK8sForm(flag)
|
||||
return &obj, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
func toK8sForm(flag featuremgmt.FeatureFlag) v0alpha1.Feature {
|
||||
return v0alpha1.Feature{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: flag.Name,
|
||||
CreationTimestamp: metav1.NewTime(flag.Created),
|
||||
},
|
||||
Spec: v0alpha1.FeatureSpec{
|
||||
Description: flag.Description,
|
||||
Stage: flag.Stage.String(),
|
||||
Owner: string(flag.Owner),
|
||||
AllowSelfServe: flag.AllowSelfServe,
|
||||
HideFromAdminPage: flag.HideFromAdminPage,
|
||||
HideFromDocs: flag.HideFromDocs,
|
||||
FrontendOnly: flag.FrontendOnly,
|
||||
RequiresDevMode: flag.RequiresDevMode,
|
||||
RequiresRestart: flag.RequiresRestart,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package featuretoggle
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
common "k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
|
||||
)
|
||||
|
||||
var _ grafanaapiserver.APIGroupBuilder = (*FeatureFlagAPIBuilder)(nil)
|
||||
|
||||
var gv = v0alpha1.SchemeGroupVersion
|
||||
|
||||
// This is used just so wire has something unique to return
|
||||
type FeatureFlagAPIBuilder struct {
|
||||
features *featuremgmt.FeatureManager
|
||||
}
|
||||
|
||||
func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager) *FeatureFlagAPIBuilder {
|
||||
return &FeatureFlagAPIBuilder{features}
|
||||
}
|
||||
|
||||
func RegisterAPIService(features *featuremgmt.FeatureManager,
|
||||
apiregistration grafanaapiserver.APIRegistrar,
|
||||
) *FeatureFlagAPIBuilder {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
return nil // skip registration unless opting into experimental apis
|
||||
}
|
||||
builder := NewFeatureFlagAPIBuilder(features)
|
||||
apiregistration.RegisterAPI(builder)
|
||||
return builder
|
||||
}
|
||||
|
||||
func (b *FeatureFlagAPIBuilder) GetGroupVersion() schema.GroupVersion {
|
||||
return gv
|
||||
}
|
||||
|
||||
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
|
||||
scheme.AddKnownTypes(gv,
|
||||
&v0alpha1.Feature{},
|
||||
&v0alpha1.FeatureList{},
|
||||
&v0alpha1.FeatureToggles{},
|
||||
&v0alpha1.FeatureTogglesList{},
|
||||
&v0alpha1.ResolvedToggleState{},
|
||||
)
|
||||
}
|
||||
|
||||
func (b *FeatureFlagAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
addKnownTypes(scheme, gv)
|
||||
|
||||
// Link this version to the internal representation.
|
||||
// This is used for server-side-apply (PATCH), and avoids the error:
|
||||
// "no kind is registered for the type"
|
||||
addKnownTypes(scheme, schema.GroupVersion{
|
||||
Group: gv.Group,
|
||||
Version: runtime.APIVersionInternal,
|
||||
})
|
||||
|
||||
// If multiple versions exist, then register conversions from zz_generated.conversion.go
|
||||
// if err := playlist.RegisterConversions(scheme); err != nil {
|
||||
// return err
|
||||
// }
|
||||
metav1.AddToGroupVersion(scheme, gv)
|
||||
return scheme.SetVersionPriority(gv)
|
||||
}
|
||||
|
||||
func (b *FeatureFlagAPIBuilder) GetAPIGroupInfo(
|
||||
scheme *runtime.Scheme,
|
||||
codecs serializer.CodecFactory, // pointer?
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs)
|
||||
|
||||
featureStore := NewFeaturesStorage(b.features.GetFlags())
|
||||
toggleStore := NewTogglesStorage(b.features)
|
||||
|
||||
storage := map[string]rest.Storage{}
|
||||
storage[featureStore.resource.StoragePath()] = featureStore
|
||||
storage[toggleStore.resource.StoragePath()] = toggleStore
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage
|
||||
return &apiGroupInfo, nil
|
||||
}
|
||||
|
||||
func (b *FeatureFlagAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||
return v0alpha1.GetOpenAPIDefinitions
|
||||
}
|
||||
|
||||
func (b *FeatureFlagAPIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
return nil // default authorizer is fine
|
||||
}
|
||||
|
||||
// Register additional routes with the server
|
||||
func (b *FeatureFlagAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
|
||||
defs := v0alpha1.GetOpenAPIDefinitions(func(path string) spec.Ref { return spec.Ref{} })
|
||||
stateSchema := defs["github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.ResolvedToggleState"].Schema
|
||||
|
||||
tags := []string{"Editor"}
|
||||
return &grafanaapiserver.APIRoutes{
|
||||
Root: []grafanaapiserver.APIRouteHandler{
|
||||
{
|
||||
Path: "current",
|
||||
Spec: &spec3.PathProps{
|
||||
Get: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Tags: tags,
|
||||
Summary: "Current configuration with details",
|
||||
Description: "Show details about the current flags and where they come from",
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
StatusCodeResponses: map[int]*spec3.Response{
|
||||
200: {
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: &stateSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
Description: "OK",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Patch: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Tags: tags,
|
||||
Summary: "Update individual toggles",
|
||||
Description: "Patch some of the toggles (keyed by the toggle name)",
|
||||
RequestBody: &spec3.RequestBody{
|
||||
RequestBodyProps: spec3.RequestBodyProps{
|
||||
Required: true,
|
||||
Description: "flags to change",
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: &stateSchema,
|
||||
Example: &v0alpha1.ResolvedToggleState{
|
||||
Enabled: map[string]bool{
|
||||
featuremgmt.FlagAutoMigrateOldPanels: true,
|
||||
featuremgmt.FlagAngularDeprecationUI: false,
|
||||
},
|
||||
},
|
||||
Examples: map[string]*spec3.Example{
|
||||
"enable-auto-migrate": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "enable auto-migrate panels",
|
||||
Description: "example descr",
|
||||
Value: &v0alpha1.ResolvedToggleState{
|
||||
Enabled: map[string]bool{
|
||||
featuremgmt.FlagAutoMigrateOldPanels: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"disable-auto-migrate": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "disable auto-migrate panels",
|
||||
Description: "disable descr",
|
||||
Value: &v0alpha1.ResolvedToggleState{
|
||||
Enabled: map[string]bool{
|
||||
featuremgmt.FlagAutoMigrateOldPanels: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
StatusCodeResponses: map[int]*spec3.Response{
|
||||
200: {
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {},
|
||||
},
|
||||
Description: "OK",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: b.handleCurrentStatus,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package featuretoggle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
|
||||
)
|
||||
|
||||
var (
|
||||
_ rest.Storage = (*togglesStorage)(nil)
|
||||
_ rest.Scoper = (*togglesStorage)(nil)
|
||||
_ rest.SingularNameProvider = (*togglesStorage)(nil)
|
||||
_ rest.Lister = (*togglesStorage)(nil)
|
||||
_ rest.Getter = (*togglesStorage)(nil)
|
||||
)
|
||||
|
||||
type togglesStorage struct {
|
||||
resource *common.ResourceInfo
|
||||
tableConverter rest.TableConvertor
|
||||
|
||||
// The startup toggles
|
||||
startup *v0alpha1.FeatureToggles
|
||||
}
|
||||
|
||||
func NewTogglesStorage(features *featuremgmt.FeatureManager) *togglesStorage {
|
||||
resourceInfo := v0alpha1.TogglesResourceInfo
|
||||
return &togglesStorage{
|
||||
resource: &resourceInfo,
|
||||
startup: &v0alpha1.FeatureToggles{
|
||||
TypeMeta: resourceInfo.TypeMeta(),
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "startup",
|
||||
Namespace: "system",
|
||||
CreationTimestamp: metav1.Now(),
|
||||
},
|
||||
Spec: features.GetStartupFlags(),
|
||||
},
|
||||
tableConverter: rest.NewDefaultTableConvertor(resourceInfo.GroupResource()),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *togglesStorage) New() runtime.Object {
|
||||
return s.resource.NewFunc()
|
||||
}
|
||||
|
||||
func (s *togglesStorage) Destroy() {}
|
||||
|
||||
func (s *togglesStorage) NamespaceScoped() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *togglesStorage) GetSingularName() string {
|
||||
return s.resource.GetSingularName()
|
||||
}
|
||||
|
||||
func (s *togglesStorage) NewList() runtime.Object {
|
||||
return s.resource.NewListFunc()
|
||||
}
|
||||
|
||||
func (s *togglesStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
func (s *togglesStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
flags := &v0alpha1.FeatureTogglesList{
|
||||
Items: []v0alpha1.FeatureToggles{*s.startup},
|
||||
}
|
||||
return flags, nil
|
||||
}
|
||||
|
||||
func (s *togglesStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, false) // allow system
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Value != "" && info.Value != "system" {
|
||||
return nil, fmt.Errorf("only system namespace is currently supported")
|
||||
}
|
||||
if name != "startup" {
|
||||
return nil, fmt.Errorf("only system/startup is currently supported")
|
||||
}
|
||||
return s.startup, nil
|
||||
}
|
||||
Reference in New Issue
Block a user