diff --git a/connect-plugin/commands/activation.go b/connect-plugin/commands/activation.go new file mode 100644 index 0000000..fe2b1e5 --- /dev/null +++ b/connect-plugin/commands/activation.go @@ -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 +} diff --git a/connect-plugin/commands/activation_test.go b/connect-plugin/commands/activation_test.go new file mode 100644 index 0000000..09333b6 --- /dev/null +++ b/connect-plugin/commands/activation_test.go @@ -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 +} diff --git a/connect-plugin/commands/root.go b/connect-plugin/commands/root.go index b19fa19..9ed2e60 100644 --- a/connect-plugin/commands/root.go +++ b/connect-plugin/commands/root.go @@ -24,15 +24,17 @@ import ( ) type options struct { - baseURL string - tokenFile string - timeout time.Duration - verbose bool + baseURL string + tokenFile string + timeout time.Duration + verbose bool + serviceGate func(*cobra.Command, string) error } // Add installs the daemon-backed commands on the plugin root. func Add(root *cobra.Command) { opts := &options{} + opts.serviceGate = runConnectServiceGate previousPreRun := root.PersistentPreRunE previousPreRunPlain := root.PersistentPreRun root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { @@ -41,11 +43,17 @@ func Add(root *cobra.Command) { return fmt.Errorf("unsupported output format %q (use table, json, or yaml)", format) } if previousPreRun != nil { - return previousPreRun(cmd, args) + if err := previousPreRun(cmd, args); err != nil { + return err + } } if previousPreRunPlain != nil { previousPreRunPlain(cmd, args) } + if connectServiceGateRequired(cmd) { + projectID, _ := cmd.Flags().GetString("project") + return opts.serviceGate(cmd, strings.TrimSpace(projectID)) + } return nil } root.PersistentFlags().StringVar(&opts.baseURL, "daemon-url", connectapi.DefaultBaseURL, "Datum Connect daemon URL") diff --git a/connect-plugin/go.mod b/connect-plugin/go.mod index 6ab1af0..c282c38 100644 --- a/connect-plugin/go.mod +++ b/connect-plugin/go.mod @@ -1,15 +1,64 @@ module go.datum.net/datumctl-plugins/connect -go 1.25.8 +go 1.26.0 require ( github.com/kardianos/service v1.2.4 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 go.datum.net/datumctl v0.15.0 + go.miloapis.com/service-catalog v0.13.1 golang.org/x/sys v0.45.0 golang.org/x/term v0.43.0 gopkg.in/yaml.v3 v3.0.1 + k8s.io/apimachinery v0.35.3 + k8s.io/client-go v0.35.3 ) -require github.com/inconshreveable/mousetrap v1.1.0 // indirect +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/google/gnostic-models v0.7.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/api v0.35.3 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect + k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect + sigs.k8s.io/controller-runtime v0.23.3 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/connect-plugin/go.sum b/connect-plugin/go.sum index 25a03a7..dd0ce1c 100644 --- a/connect-plugin/go.sum +++ b/connect-plugin/go.sum @@ -1,22 +1,159 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk= github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.datum.net/datumctl v0.15.0 h1:dOrnfwWyhQ0Yp42jTUiMu3w/ySkftz2CXF7hsuWp4gE= go.datum.net/datumctl v0.15.0/go.mod h1:rwu8XWb0FeMzX8vCu+UxKLw89DAkyLOh70PNbDaotac= +go.miloapis.com/service-catalog v0.13.1 h1:KZzyFrcDg7aUqZNcUXrU5m83qHnNpwW2Zgby5xU9Gm8= +go.miloapis.com/service-catalog v0.13.1/go.mod h1:8kJJ+kw2GUzJk3ssgwtlsRuqrAHGSRxLE3K65sXaSfo= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= +k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= +k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= +k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= +k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/connect-plugin/internal/daemonservice/upgrade.go b/connect-plugin/internal/daemonservice/upgrade.go index d83ea57..4495d36 100644 --- a/connect-plugin/internal/daemonservice/upgrade.go +++ b/connect-plugin/internal/daemonservice/upgrade.go @@ -9,6 +9,7 @@ import ( "io" "os" "os/exec" + "path" "path/filepath" "regexp" "runtime" @@ -233,7 +234,7 @@ func replaceLaunchdExecutable(content []byte, replacement string) (string, []byt if err := xml.Unmarshal(append(append([]byte(""), encoded...), []byte("")...), &old); err != nil { return "", nil, fmt.Errorf("decode the service executable path: %w", err) } - if !filepath.IsAbs(old) || (filepath.Base(old) != "datum-connect-daemon" && filepath.Base(old) != "datum-connectd") { + if !path.IsAbs(old) || (path.Base(old) != "datum-connect-daemon" && path.Base(old) != "datum-connectd") { return "", nil, fmt.Errorf("the service points to an unrecognized executable") } var escaped bytes.Buffer @@ -264,7 +265,7 @@ func replaceSystemdExecutable(content []byte, replacement string) (string, []byt } old = strings.ReplaceAll(command[:end], `\x20`, " ") } - if execStartCount != 1 || !filepath.IsAbs(old) || (filepath.Base(old) != "datum-connect-daemon" && filepath.Base(old) != "datum-connectd") { + if execStartCount != 1 || !path.IsAbs(old) || (path.Base(old) != "datum-connect-daemon" && path.Base(old) != "datum-connectd") { return "", nil, fmt.Errorf("the service does not have one recognized ExecStart executable") } escapedOld := strings.ReplaceAll(old, " ", `\x20`) diff --git a/connect-plugin/internal/daemonservice/upgrade_test.go b/connect-plugin/internal/daemonservice/upgrade_test.go index b44a3b8..2ca73cc 100644 --- a/connect-plugin/internal/daemonservice/upgrade_test.go +++ b/connect-plugin/internal/daemonservice/upgrade_test.go @@ -3,11 +3,15 @@ package daemonservice import ( "os" "path/filepath" + "runtime" "strings" "testing" ) func TestDaemonExecutableVersion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the synthetic daemon fixture is a POSIX shell script") + } path := filepath.Join(t.TempDir(), "datum-connectd") if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf 'datum-connectd v1.2.3-preview.4\\n'\n"), 0700); err != nil { t.Fatal(err) diff --git a/connect-plugin/main.go b/connect-plugin/main.go index a7ebd42..8a08ed3 100644 --- a/connect-plugin/main.go +++ b/connect-plugin/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" "os/exec" @@ -13,6 +14,7 @@ import ( "go.datum.net/datumctl-plugins/connect/commands" "go.datum.net/datumctl-plugins/connect/internal/daemonservice" "go.datum.net/datumctl/plugin" + "go.miloapis.com/service-catalog/pkg/activation" ) // Overridden at build time via -ldflags "-X main.version=vX.Y.Z". @@ -56,8 +58,14 @@ func main() { }) if err := cmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + // The activation SDK writes its complete user-facing message itself. + // Avoid printing the typed error a second time, and retain the SDK's + // documented exit codes for automation. + var activationErr *activation.Error + if !errors.As(err, &activationErr) { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } + os.Exit(activation.ExitCodeOf(err)) } } diff --git a/scripts/daemon-e2e.py b/scripts/daemon-e2e.py index bb70608..9219b6a 100644 --- a/scripts/daemon-e2e.py +++ b/scripts/daemon-e2e.py @@ -49,6 +49,44 @@ def handle_api(self): return self.reply(401, {}) self.observed_tokens.add(bearer) parts = self.path.strip('/').split('/') + # The OIDC fixture exercises the CLI's service-catalog activation gate + # before reaching the daemon. Model an already-active Connect service; + # activation behavior itself is covered by focused Go tests. + if parts[-4:] == ['apis', 'services.miloapis.com', 'v1alpha1', 'services']: + return self.reply(200, { + 'apiVersion': 'services.miloapis.com/v1alpha1', + 'kind': 'ServiceList', + 'metadata': {}, + 'items': [{ + 'apiVersion': 'services.miloapis.com/v1alpha1', + 'kind': 'Service', + 'metadata': {'name': 'connect-datumapis-com'}, + 'spec': { + 'displayName': 'Connect', + 'serviceName': 'connect.datumapis.com', + 'phase': 'Published', + 'owner': {'producerProjectRef': {'name': 'provider'}}, + 'enablementPolicy': {'mode': 'GatedByProvider'}, + }, + }], + }) + if parts[-4:] == ['apis', 'services.miloapis.com', 'v1alpha1', 'serviceentitlements']: + return self.reply(200, { + 'apiVersion': 'services.miloapis.com/v1alpha1', + 'kind': 'ServiceEntitlementList', + 'metadata': {}, + 'items': [{ + 'apiVersion': 'services.miloapis.com/v1alpha1', + 'kind': 'ServiceEntitlement', + 'metadata': {'name': 'connect-datumapis-com', 'resourceVersion': '1'}, + 'spec': {'serviceRef': {'name': 'connect-datumapis-com'}}, + 'status': { + 'phase': 'Active', + 'serviceName': 'connect.datumapis.com', + 'conditions': [{'type': 'Ready', 'status': 'True'}], + }, + }], + }) if 'connectorclasses' in parts: item = { 'metadata': {'name': 'local-masque', 'generation': 1, 'annotations': {'connect.datum.net/transport': 'masque-v1'}}, @@ -445,6 +483,7 @@ def save_helper(): save_helper() clean_env = {key: value for key, value in os.environ.items() if not key.startswith('DATUM_')} env = {**clean_env, 'DATUM_CREDENTIALS_HELPER': str(helper.resolve()), + 'TEST_DATUM_HELPER_DIR': str(root), 'DATUM_SESSION': helper_state['session'], 'DATUM_API_HOST': f'http://127.0.0.1:{platform.server_port}'} ports = {name: free_port() for name in ('alice', 'bob')} @@ -558,8 +597,11 @@ def inspect_descriptors(): roundtrip(local) inspect_descriptors() calls = [json.loads(line) for line in (root / 'helper-calls.jsonl').read_text().splitlines()] - assert calls and all(call['args'] == ['auth', 'get-token', '--session', 'isolated-test-session', - '--output', 'client.authentication.k8s.io/v1'] for call in calls) + expected_helper_args = ['auth', 'get-token', '--session', 'isolated-test-session'] + assert calls and all( + (call['args'] == expected_helper_args + ['--output', 'client.authentication.k8s.io/v1']) + or (len(call['args']) == 4 and call['args'][:3] == expected_helper_args[:3]) + for call in calls) print('PASS daemon restart pins the original host session and restores the existing Connector and dial', flush=True) helper_state['logged_out'] = True diff --git a/scripts/fixtures/datumctl-oidc-helper.py b/scripts/fixtures/datumctl-oidc-helper.py index a17c4ae..0df1b47 100644 --- a/scripts/fixtures/datumctl-oidc-helper.py +++ b/scripts/fixtures/datumctl-oidc-helper.py @@ -11,15 +11,21 @@ state = json.loads((root / 'helper-state.json').read_text()) expected = ['auth', 'get-token', '--session', state['session'], '--output', 'client.authentication.k8s.io/v1'] +raw_token_request = (len(sys.argv[1:]) == 4 and + sys.argv[1:3] == ['auth', 'get-token'] and + sys.argv[3] == '--session') with (root / 'helper-calls.jsonl').open('a') as log: log.write(json.dumps({'args': sys.argv[1:], 'generation': state['generation']}) + '\n') -if sys.argv[1:] != expected: +if sys.argv[1:] != expected and not raw_token_request: print('fixture rejects unexpected arguments or session', file=sys.stderr) sys.exit(2) if state.get('logged_out'): print('fixture session is logged out', file=sys.stderr) sys.exit(1) expiration = datetime.now(timezone.utc) + timedelta(seconds=state.get('expiry_seconds', 5)) -print(json.dumps({'apiVersion': 'client.authentication.k8s.io/v1', 'kind': 'ExecCredential', - 'status': {'token': state['token'], - 'expirationTimestamp': expiration.isoformat().replace('+00:00', 'Z')}})) +if raw_token_request: + print(state['token']) +else: + print(json.dumps({'apiVersion': 'client.authentication.k8s.io/v1', 'kind': 'ExecCredential', + 'status': {'token': state['token'], + 'expirationTimestamp': expiration.isoformat().replace('+00:00', 'Z')}}))