Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions connect-plugin/commands/activation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package commands

import (
"context"
"fmt"
"net/url"
"strings"

"github.com/spf13/cobra"
"go.datum.net/datumctl/plugin"
"go.miloapis.com/service-catalog/pkg/activation"
"k8s.io/client-go/rest"
)

const connectServiceName = "connect.datumapis.com"

// connectServiceGateRequired identifies commands that establish or use a
// cloud-side Connect session. Inspection, cleanup, and local daemon lifecycle
// commands remain usable when service access is pending or unavailable.
func connectServiceGateRequired(cmd *cobra.Command) bool {
switch cmd.Name() {
case "up", "serve", "dial", "join", "ping":
return true
default:
return false
}
}

// runConnectServiceGate uses the service-catalog CLI SDK before a command
// reaches the daemon. Self-service catalog entries are enabled immediately;
// provider-gated entries use the SDK's interactive request flow.
func runConnectServiceGate(cmd *cobra.Command, project string) error {
if project == "" {
// Let the command's existing project-selection flow produce the more
// useful error or redispatch through datumctl's context picker.
return nil
}

host := plugin.Context()
if host.APIHost == "" || host.CredentialsHelper == "" {
// Connect also supports explicitly configured standalone/automation
// paths. They have no datumctl user session with which to inspect or
// mutate service access, so preserve their existing daemon-side flow.
return nil
}

return defaultConnectServiceActivation().run(cmd, project)
}

type connectServiceActivation struct {
resolveService func(context.Context) (activation.ServiceInfo, error)
newEntitlementClient func(string) (activation.EntitlementClient, error)
io func(*cobra.Command) activation.IOStreams
}

func defaultConnectServiceActivation() connectServiceActivation {
return connectServiceActivation{
resolveService: resolveConnectService,
newEntitlementClient: newConnectEntitlementClient,
io: connectActivationIO,
}
}

func (a connectServiceActivation) run(cmd *cobra.Command, project string) error {
service, err := a.resolveService(cmd.Context())
if err != nil {
return err
}
client, err := a.newEntitlementClient(project)
if err != nil {
return err
}

io := connectActivationIO(cmd)
if a.io != nil {
io = a.io(cmd)
}
return (activation.Gate{
Service: service,
Client: client,
IO: io,
Project: project,
}).Run(cmd.Context())
}

func connectActivationIO(cmd *cobra.Command) activation.IOStreams {
return activation.IOStreams{
In: cmd.InOrStdin(),
Out: cmd.OutOrStdout(),
Err: cmd.ErrOrStderr(),
}
}

// resolveConnectService reads the live catalog entry so enablement policy and
// user-facing service metadata stay owned by service-catalog.
func resolveConnectService(ctx context.Context) (activation.ServiceInfo, error) {
cfg, err := connectRESTConfig(func(apiHost string) (string, error) {
return connectAPIBaseURL(apiHost)
})
if err != nil {
return activation.ServiceInfo{}, err
}
client, err := activation.NewCatalogRESTClient(cfg)
if err != nil {
return activation.ServiceInfo{}, fmt.Errorf("building service catalog client: %w", err)
}
services, err := client.ListServices(ctx)
if err != nil {
return activation.ServiceInfo{}, fmt.Errorf("looking up the Connect service: %w", err)
}
return activation.FindService(services, connectServiceName)
}

func newConnectEntitlementClient(project string) (activation.EntitlementClient, error) {
cfg, err := connectRESTConfig(func(apiHost string) (string, error) {
baseURL, err := connectAPIBaseURL(apiHost)
if err != nil {
return "", err
}
return fmt.Sprintf("%s/apis/resourcemanager.miloapis.com/v1alpha1/projects/%s/control-plane", baseURL, url.PathEscape(project)), nil
})
if err != nil {
return nil, err
}
client, err := activation.NewRESTClient(cfg)
if err != nil {
return nil, fmt.Errorf("building Connect service entitlement client: %w", err)
}
return client, nil
}

func connectAPIBaseURL(apiHost string) (string, error) {
raw := strings.TrimSpace(apiHost)
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
parsed, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("invalid DATUM_API_HOST %q: %w", apiHost, err)
}
if (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("invalid DATUM_API_HOST %q", apiHost)
}
parsed.Path = strings.TrimSuffix(parsed.Path, "/")
return parsed.String(), nil
}

func connectRESTConfig(hostURL func(string) (string, error)) (*rest.Config, error) {
host := plugin.Context()
if host.APIHost == "" {
return nil, fmt.Errorf("DATUM_API_HOST is not set; run this command through datumctl")
}
token, err := plugin.Token()
if err != nil {
return nil, fmt.Errorf("getting datumctl credentials: %w", err)
}
resolvedHost, err := hostURL(host.APIHost)
if err != nil {
return nil, err
}
return &rest.Config{Host: resolvedHost, BearerToken: token}, nil
}
216 changes: 216 additions & 0 deletions connect-plugin/commands/activation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package commands

import (
"bytes"
"context"
"errors"
"strings"
"testing"

"github.com/spf13/cobra"
servicesv1alpha1 "go.miloapis.com/service-catalog/api/v1alpha1"
"go.miloapis.com/service-catalog/pkg/activation"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch"
)

func TestConnectServiceGateRequired(t *testing.T) {
for _, name := range []string{"up", "serve", "dial", "join", "ping"} {
t.Run(name, func(t *testing.T) {
if !connectServiceGateRequired(&cobra.Command{Use: name}) {
t.Fatalf("%s should require Connect service access", name)
}
})
}
for _, name := range []string{"status", "down", "unserve", "hangup", "leave", "health", "doctor", "daemon", "install"} {
t.Run(name, func(t *testing.T) {
if connectServiceGateRequired(&cobra.Command{Use: name}) {
t.Fatalf("%s should remain available without Connect service access", name)
}
})
}
}

func TestConnectAPIBaseURL(t *testing.T) {
tests := []struct {
name string
host string
want string
wantErr bool
}{
{name: "datumctl hostname", host: "api.staging.env.datum.net", want: "https://api.staging.env.datum.net"},
{name: "explicit https", host: "https://api.staging.env.datum.net/", want: "https://api.staging.env.datum.net"},
{name: "local test server", host: "http://127.0.0.1:8443", want: "http://127.0.0.1:8443"},
{name: "unsupported scheme", host: "file:///tmp/api", wantErr: true},
{name: "query", host: "https://api.example.test?scope=other", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := connectAPIBaseURL(tt.host)
if (err != nil) != tt.wantErr {
t.Fatalf("connectAPIBaseURL(%q) error = %v, wantErr %v", tt.host, err, tt.wantErr)
}
if got != tt.want {
t.Fatalf("connectAPIBaseURL(%q) = %q, want %q", tt.host, got, tt.want)
}
})
}
}

func TestConnectServiceActivationAlreadyActive(t *testing.T) {
client := &fakeEntitlementClient{entitlements: activeConnectEntitlements()}
project := "test-project"
activationFlow := connectServiceActivation{
resolveService: func(context.Context) (activation.ServiceInfo, error) {
return testConnectServiceInfo(servicesv1alpha1.EnablementModeGatedByProvider), nil
},
newEntitlementClient: func(got string) (activation.EntitlementClient, error) {
if got != project {
t.Fatalf("project = %q, want %q", got, project)
}
return client, nil
},
}

cmd := &cobra.Command{}
cmd.SetContext(context.Background())
if err := activationFlow.run(cmd, project); err != nil {
t.Fatalf("activation gate for active service: %v", err)
}
if client.creates != 0 {
t.Fatalf("created %d entitlements for an active service", client.creates)
}
}

func TestConnectServiceActivationEnablesSelfService(t *testing.T) {
client := &fakeEntitlementClient{}
activationFlow := connectServiceActivation{
resolveService: func(context.Context) (activation.ServiceInfo, error) {
return testConnectServiceInfo(servicesv1alpha1.EnablementModeSelfService), nil
},
newEntitlementClient: func(string) (activation.EntitlementClient, error) {
return client, nil
},
}
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
var stderr bytes.Buffer
cmd.SetIn(strings.NewReader(""))
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&stderr)

if err := activationFlow.run(cmd, "test-project"); err != nil {
t.Fatalf("activation gate: %v", err)
}
if client.creates != 1 || client.created == nil {
t.Fatalf("created entitlement count = %d, want 1", client.creates)
}
if got := client.created.Spec.ServiceRef.Name; got != "connect-datumapis-com" {
t.Fatalf("serviceRef.name = %q", got)
}
for _, want := range []string{"Enabling connect", "Connect is now enabled"} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("stderr %q does not contain %q", stderr.String(), want)
}
}
}

func TestConnectServiceActivationRequestsProviderGatedAccess(t *testing.T) {
client := &fakeEntitlementClient{}
activationFlow := connectServiceActivation{
resolveService: func(context.Context) (activation.ServiceInfo, error) {
return testConnectServiceInfo(servicesv1alpha1.EnablementModeGatedByProvider), nil
},
newEntitlementClient: func(string) (activation.EntitlementClient, error) {
return client, nil
},
io: func(cmd *cobra.Command) activation.IOStreams {
return connectActivationIO(cmd).WithInteractive(true)
},
}
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
var stderr bytes.Buffer
cmd.SetIn(strings.NewReader("yes\n"))
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&stderr)

if err := activationFlow.run(cmd, "test-project"); err != nil {
t.Fatalf("activation gate: %v", err)
}
if client.creates != 1 {
t.Fatalf("created entitlement count = %d, want 1", client.creates)
}
for _, want := range []string{"needs approval", "Would you like to request access?", "Requesting access to connect"} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("stderr %q does not contain %q", stderr.String(), want)
}
}
}

func testConnectServiceInfo(mode servicesv1alpha1.EnablementMode) activation.ServiceInfo {
return activation.ServiceInfo{
ObjectName: "connect-datumapis-com",
CanonicalName: connectServiceName,
DisplayName: "Connect",
EnablementMode: mode,
}
}

func activeConnectEntitlements() *servicesv1alpha1.ServiceEntitlementList {
return &servicesv1alpha1.ServiceEntitlementList{Items: []servicesv1alpha1.ServiceEntitlement{*activeConnectEntitlement()}}
}

func activeConnectEntitlement() *servicesv1alpha1.ServiceEntitlement {
return &servicesv1alpha1.ServiceEntitlement{
ObjectMeta: metav1.ObjectMeta{Name: "connect-datumapis-com", ResourceVersion: "2"},
Spec: servicesv1alpha1.ServiceEntitlementSpec{
ServiceRef: servicesv1alpha1.ServiceRef{Name: "connect-datumapis-com"},
},
Status: servicesv1alpha1.ServiceEntitlementStatus{
Phase: servicesv1alpha1.EntitlementPhaseActive,
ServiceName: connectServiceName,
Conditions: []metav1.Condition{{
Type: servicesv1alpha1.ConditionTypeReady,
Status: metav1.ConditionTrue,
}},
},
}
}

type fakeEntitlementClient struct {
entitlements *servicesv1alpha1.ServiceEntitlementList
created *servicesv1alpha1.ServiceEntitlement
creates int
}

func (f *fakeEntitlementClient) List(context.Context) (*servicesv1alpha1.ServiceEntitlementList, error) {
if f.entitlements == nil {
return &servicesv1alpha1.ServiceEntitlementList{}, nil
}
return f.entitlements.DeepCopy(), nil
}

func (f *fakeEntitlementClient) Get(context.Context, string) (*servicesv1alpha1.ServiceEntitlement, error) {
if f.created != nil {
return activeConnectEntitlement(), nil
}
return nil, errors.New("unexpected Get")
}

func (f *fakeEntitlementClient) Create(_ context.Context, entitlement *servicesv1alpha1.ServiceEntitlement) error {
f.creates++
f.created = entitlement.DeepCopy()
entitlement.ResourceVersion = "1"
return nil
}

func (f *fakeEntitlementClient) Delete(context.Context, *servicesv1alpha1.ServiceEntitlement) error {
return errors.New("unexpected Delete")
}

func (f *fakeEntitlementClient) Watch(context.Context, string, string) (watch.Interface, error) {
watcher := watch.NewRaceFreeFake()
watcher.Modify(activeConnectEntitlement())
return watcher, nil
}
Loading
Loading