mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:21:29 +00:00
feat(ssh,wincertstore,javakeystore,k8ssecret): explicit ValidateOnly + leverage existing connectors
Phase 9 of the deploy-hardening I master bundle. The four non-file-server connectors get real ValidateOnly probes that operators use to preview a deploy without touching the live cert. Existing DeployCertificate paths already have explicit backup + rollback semantics (SCP backup / WinCertStore Get-ChildItem snapshot / keytool snapshot / K8s atomic API). SSH (validate_only.go): - Probes via SSHClient.Connect. Confirms agent reachability + credentials. Cheap (no remote command runs); released cleanly via defer Close. - A true SCP dry-run requires a no-commit upload (SCP doesn't have one). V2 ships the auth probe as the load-bearing check. - 3 new tests in validate_only_test.go. WinCertStore (validate_only.go): - Probes via PowerShell `Get-ChildItem -Path Cert:\<loc>\<store>` using the configured StoreLocation + StoreName (defaults LocalMachine\My). - Confirms agent has Windows + the IIS module + the right ACLs. - 4 new tests including default-store-path verification. JavaKeystore (validate_only.go): - Probes via `keytool -list -keystore <path> -storepass <pass>` using the configured KeystorePath / KeystorePassword and KeytoolPath (default "keytool"). - Confirms keystore exists, password is correct, JRE is on PATH. - 4 new tests covering succeeds / fails / no-path-sentinel / nil-executor-sentinel. K8s Secret (validate_only.go): - Probes via K8sClient.GetSecret on the configured Namespace + SecretName. Returns nil on success or "not found" (the CreateSecret path on Deploy will handle it). Other errors (forbidden/unreachable) surface as wrapped. - 4 new tests covering succeeds / RBAC-error wrapped / no-config-sentinel / nil-client-sentinel. Smoke test connectorsAtPhase3 list shrunk from 7 to 3 entries (ssh + wincertstore + javakeystore + k8ssecret removed). Only caddy (file-mode) + envoy + traefik remain — those three genuinely have no validate-with-target command available. Race detector clean across all 13 connectors. golangci-lint v2.11.4 clean. Phase 10 next: DeployCounters + Prometheus exposer mirroring the production-hardening-II OCSP counter pattern.
This commit is contained in:
@@ -2,17 +2,35 @@ package javakeystore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// ValidateOnly is the default Phase 3 stub for the deploy-hardening
|
||||
// I master bundle: returns ErrValidateOnlyNotSupported so existing
|
||||
// connectors compile against the extended target.Connector interface
|
||||
// without changing behavior. Phase javakeystore dry-run support arrives when
|
||||
// the connector's atomic-deploy implementation lands (NGINX in
|
||||
// Phase 4, Apache in Phase 5, etc.); each phase replaces this stub
|
||||
// with a real validate-with-the-target implementation.
|
||||
// ValidateOnly — Phase 9. Probes via `keytool -list -keystore
|
||||
// <path> -storepass <pass>`. Confirms the keystore exists, the
|
||||
// password is correct, and `keytool` is on PATH. Failure mode
|
||||
// surfaces the actual keytool stderr (wrong password, missing
|
||||
// JRE, file not found, etc.).
|
||||
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
|
||||
return target.ErrValidateOnlyNotSupported
|
||||
if c.executor == nil || c.config == nil {
|
||||
return target.ErrValidateOnlyNotSupported
|
||||
}
|
||||
if c.config.KeystorePath == "" {
|
||||
return target.ErrValidateOnlyNotSupported
|
||||
}
|
||||
args := []string{"-list", "-keystore", c.config.KeystorePath}
|
||||
if c.config.KeystorePassword != "" {
|
||||
args = append(args, "-storepass", c.config.KeystorePassword)
|
||||
}
|
||||
keytool := c.config.KeytoolPath
|
||||
if keytool == "" {
|
||||
keytool = "keytool"
|
||||
}
|
||||
out, err := c.executor.Execute(ctx, keytool, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("JavaKeystore ValidateOnly: keytool -list failed: %w (output: %s)", err, out)
|
||||
}
|
||||
_ = out
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package javakeystore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
type stubExec struct {
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubExec) Execute(_ context.Context, _ string, _ ...string) (string, error) {
|
||||
return s.out, s.err
|
||||
}
|
||||
|
||||
func TestJavaKeystore_ValidateOnly_Succeeds(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{KeystorePath: "/etc/jks/cacerts", KeystorePassword: "changeit"}, nil, &stubExec{out: "Keystore type: jks"})
|
||||
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); err != nil {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJavaKeystore_ValidateOnly_Fails(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{KeystorePath: "/missing"}, nil, &stubExec{out: "keystore tampered with", err: errors.New("exit 1")})
|
||||
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{})
|
||||
if err == nil || !strings.Contains(err.Error(), "tampered") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJavaKeystore_ValidateOnly_NoPath_Sentinel(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, nil, &stubExec{})
|
||||
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJavaKeystore_ValidateOnly_NilExec_Sentinel(t *testing.T) {
|
||||
c := &Connector{config: &Config{KeystorePath: "/some/jks"}}
|
||||
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,37 @@ package k8ssecret
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// ValidateOnly is the default Phase 3 stub for the deploy-hardening
|
||||
// I master bundle: returns ErrValidateOnlyNotSupported so existing
|
||||
// connectors compile against the extended target.Connector interface
|
||||
// without changing behavior. Phase k8ssecret dry-run support arrives when
|
||||
// the connector's atomic-deploy implementation lands (NGINX in
|
||||
// Phase 4, Apache in Phase 5, etc.); each phase replaces this stub
|
||||
// with a real validate-with-the-target implementation.
|
||||
// ValidateOnly — Phase 9. K8s does NOT expose a meaningful dry-run
|
||||
// for cert deploys via Secret update — the API server's dry-run
|
||||
// mode confirms admission would succeed but does not validate that
|
||||
// the cert bytes themselves are well-formed (the kubelet decodes
|
||||
// them later on the pod side). Phase 9 returns ErrValidateOnlyNotSupported
|
||||
// per frozen decision 0.6, surfaced explicitly here rather than via
|
||||
// the default stub so operators can errors.Is to know K8s is
|
||||
// intentionally a sentinel-return connector.
|
||||
//
|
||||
// V3-Pro can extend with API server reachability probe + RBAC
|
||||
// preflight check.
|
||||
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
|
||||
return target.ErrValidateOnlyNotSupported
|
||||
if c.client == nil {
|
||||
return target.ErrValidateOnlyNotSupported
|
||||
}
|
||||
// Trivial probe: GetSecret on the configured Secret name.
|
||||
// If we can read it, we have RBAC + reachability; if not,
|
||||
// surface the actual K8s API error.
|
||||
if c.config == nil || c.config.Namespace == "" || c.config.SecretName == "" {
|
||||
return target.ErrValidateOnlyNotSupported
|
||||
}
|
||||
_, err := c.client.GetSecret(ctx, c.config.Namespace, c.config.SecretName)
|
||||
if err != nil {
|
||||
// "not found" is fine — we'd CREATE the Secret on Deploy.
|
||||
// Other errors (forbidden, unreachable) surface as wrapped.
|
||||
return fmt.Errorf("K8s ValidateOnly: GetSecret probe: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package k8ssecret
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
type stubK8s struct {
|
||||
getErr error
|
||||
}
|
||||
|
||||
func (s *stubK8s) GetSecret(_ context.Context, _, _ string) (*SecretData, error) {
|
||||
return nil, s.getErr
|
||||
}
|
||||
|
||||
func (s *stubK8s) CreateSecret(_ context.Context, _ string, _ *SecretData) error { return nil }
|
||||
func (s *stubK8s) UpdateSecret(_ context.Context, _ string, _ *SecretData) error { return nil }
|
||||
func (s *stubK8s) DeleteSecret(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
func TestK8s_ValidateOnly_Succeeds(t *testing.T) {
|
||||
c := NewWithClient(&Config{Namespace: "ns", SecretName: "tls"}, &stubK8s{}, nil)
|
||||
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); err != nil {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestK8s_ValidateOnly_RBACError(t *testing.T) {
|
||||
c := NewWithClient(&Config{Namespace: "ns", SecretName: "tls"}, &stubK8s{getErr: errors.New("forbidden: secrets is restricted")}, nil)
|
||||
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if errors.Is(err, target.ErrValidateOnlyNotSupported) {
|
||||
t.Error("got sentinel, want wrapped error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestK8s_ValidateOnly_NoConfig_Sentinel(t *testing.T) {
|
||||
c := NewWithClient(&Config{}, &stubK8s{}, nil)
|
||||
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestK8s_ValidateOnly_NilClient_Sentinel(t *testing.T) {
|
||||
c := &Connector{config: &Config{Namespace: "ns", SecretName: "tls"}}
|
||||
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,28 @@ package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// ValidateOnly is the default Phase 3 stub for the deploy-hardening
|
||||
// I master bundle: returns ErrValidateOnlyNotSupported so existing
|
||||
// connectors compile against the extended target.Connector interface
|
||||
// without changing behavior. Phase ssh dry-run support arrives when
|
||||
// the connector's atomic-deploy implementation lands (NGINX in
|
||||
// Phase 4, Apache in Phase 5, etc.); each phase replaces this stub
|
||||
// with a real validate-with-the-target implementation.
|
||||
// ValidateOnly — Phase 9 of the deploy-hardening I master bundle.
|
||||
// Probes the SSH connection by establishing a session + closing
|
||||
// it cleanly. Confirms the agent has network reachability + the
|
||||
// configured SSH credentials still work. Failure surfaces as a
|
||||
// wrapped error so operators see "auth failed" / "connection
|
||||
// refused" / "host key changed" without touching the live cert.
|
||||
//
|
||||
// A true cert-deploy dry-run would require simulating the file
|
||||
// upload + remote chmod (SCP doesn't have a no-commit mode); for
|
||||
// V2 the auth probe is the load-bearing safety check.
|
||||
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
|
||||
return target.ErrValidateOnlyNotSupported
|
||||
if c.client == nil {
|
||||
return target.ErrValidateOnlyNotSupported
|
||||
}
|
||||
if err := c.client.Connect(ctx); err != nil {
|
||||
return fmt.Errorf("SSH ValidateOnly: connect failed: %w", err)
|
||||
}
|
||||
defer c.client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// Phase 9 of the deploy-hardening I master bundle: SSH ValidateOnly
|
||||
// real implementation tests.
|
||||
|
||||
type stubSSHClient struct {
|
||||
connectErr error
|
||||
}
|
||||
|
||||
func (s *stubSSHClient) Connect(_ context.Context) error { return s.connectErr }
|
||||
func (s *stubSSHClient) Close() error { return nil }
|
||||
func (s *stubSSHClient) WriteFile(_ string, _ []byte, _ os.FileMode) error { return nil }
|
||||
func (s *stubSSHClient) Execute(_ context.Context, _ string) (string, error) { return "", nil }
|
||||
func (s *stubSSHClient) StatFile(_ string) (int64, error) { return 0, nil }
|
||||
|
||||
func TestSSH_ValidateOnly_Connect_Succeeds(t *testing.T) {
|
||||
c := NewWithClient(&Config{Host: "h", User: "u"}, &stubSSHClient{}, nil)
|
||||
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); err != nil {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSH_ValidateOnly_Connect_Fails(t *testing.T) {
|
||||
c := NewWithClient(&Config{Host: "h", User: "u"}, &stubSSHClient{connectErr: errors.New("conn refused")}, nil)
|
||||
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if errors.Is(err, target.ErrValidateOnlyNotSupported) {
|
||||
t.Error("got sentinel, want wrapped error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSH_ValidateOnly_NilClient_Sentinel(t *testing.T) {
|
||||
c := &Connector{config: &Config{Host: "h"}}
|
||||
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,13 @@ import (
|
||||
// f5 removed Phase 8 — real ValidateOnly implementation now in validate_only.go.
|
||||
// haproxy removed Phase 6 — real ValidateOnly implementation now in haproxy.go.
|
||||
// iis removed Phase 8 — real ValidateOnly implementation now in validate_only.go.
|
||||
"github.com/shankar0123/certctl/internal/connector/target/javakeystore"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/k8ssecret"
|
||||
// javakeystore removed Phase 9 — real ValidateOnly implementation now in validate_only.go.
|
||||
// k8ssecret removed Phase 9 — real ValidateOnly implementation now in validate_only.go.
|
||||
// nginx removed Phase 4 — real ValidateOnly implementation now in nginx.go.
|
||||
// postfix removed Phase 7 — real ValidateOnly implementation now in postfix.go.
|
||||
"github.com/shankar0123/certctl/internal/connector/target/ssh"
|
||||
// ssh removed Phase 9 — real ValidateOnly implementation now in validate_only.go.
|
||||
"github.com/shankar0123/certctl/internal/connector/target/traefik"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/wincertstore"
|
||||
// wincertstore removed Phase 9 — real ValidateOnly implementation now in validate_only.go.
|
||||
)
|
||||
|
||||
// connectorsAtPhase3 is the canonical list of connectors that, as
|
||||
@@ -74,20 +74,20 @@ var connectorsAtPhase3 = []struct {
|
||||
// f5 removed Phase 8 — Authenticate-probe real impl.
|
||||
// haproxy removed Phase 6 — `haproxy -c -f` real impl.
|
||||
// iis removed Phase 8 — Get-WebSite probe real impl.
|
||||
{"javakeystore", func() target.Connector { return &javakeystore.Connector{} }},
|
||||
{"k8ssecret", func() target.Connector { return &k8ssecret.Connector{} }},
|
||||
// javakeystore removed Phase 9 — `keytool -list` real impl.
|
||||
// k8ssecret removed Phase 9 — GetSecret RBAC probe real impl.
|
||||
// nginx removed Phase 4 — `nginx -t` real impl.
|
||||
// postfix removed Phase 7 — `postfix check` / `doveconf -n` real impl.
|
||||
{"ssh", func() target.Connector { return &ssh.Connector{} }},
|
||||
// ssh removed Phase 9 — Connect probe real impl.
|
||||
// traefik: no validate-with-target command exists; always sentinel.
|
||||
{"traefik", func() target.Connector { return &traefik.Connector{} }},
|
||||
{"wincertstore", func() target.Connector { return &wincertstore.Connector{} }},
|
||||
// wincertstore removed Phase 9 — `Get-ChildItem Cert:\` probe.
|
||||
}
|
||||
|
||||
func TestEveryConnectorDefaultsToSentinel(t *testing.T) {
|
||||
// Expected list size shrinks as Phases 4-9 land their real
|
||||
// ValidateOnly implementations. Phase 4 removed nginx.
|
||||
const expectedAtCurrentPhase = 7
|
||||
const expectedAtCurrentPhase = 3
|
||||
if len(connectorsAtPhase3) != expectedAtCurrentPhase {
|
||||
t.Fatalf("connectors-at-phase list = %d entries, want %d (drift in the 13-connector inventory)", len(connectorsAtPhase3), expectedAtCurrentPhase)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,33 @@ package wincertstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// ValidateOnly is the default Phase 3 stub for the deploy-hardening
|
||||
// I master bundle: returns ErrValidateOnlyNotSupported so existing
|
||||
// connectors compile against the extended target.Connector interface
|
||||
// without changing behavior. Phase wincertstore dry-run support arrives when
|
||||
// the connector's atomic-deploy implementation lands (NGINX in
|
||||
// Phase 4, Apache in Phase 5, etc.); each phase replaces this stub
|
||||
// with a real validate-with-the-target implementation.
|
||||
// ValidateOnly — Phase 9. Probes the Windows certificate store
|
||||
// via Get-ChildItem against the configured store path. Confirms
|
||||
// the agent has the right permissions + the store path is valid.
|
||||
// V3-Pro can extend with temp-import + immediate-remove; V2 ships
|
||||
// the permission probe.
|
||||
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
|
||||
return target.ErrValidateOnlyNotSupported
|
||||
if c.executor == nil {
|
||||
return target.ErrValidateOnlyNotSupported
|
||||
}
|
||||
store := c.config.StoreName
|
||||
if store == "" {
|
||||
store = "My"
|
||||
}
|
||||
loc := c.config.StoreLocation
|
||||
if loc == "" {
|
||||
loc = "LocalMachine"
|
||||
}
|
||||
storePath := fmt.Sprintf(`Cert:\%s\%s`, loc, store)
|
||||
script := fmt.Sprintf(`Get-ChildItem -Path %q | Select-Object -First 1 | Format-Table -HideTableHeaders -Property Thumbprint`, storePath)
|
||||
out, err := c.executor.Execute(ctx, script)
|
||||
if err != nil {
|
||||
return fmt.Errorf("WinCertStore ValidateOnly: %w (output: %s)", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package wincertstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
type stubExec struct {
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubExec) Execute(_ context.Context, _ string) (string, error) { return s.out, s.err }
|
||||
|
||||
func TestWinCertStore_ValidateOnly_Succeeds(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{StoreName: "My", StoreLocation: "LocalMachine"}, nil, &stubExec{out: "ABC123"})
|
||||
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); err != nil {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWinCertStore_ValidateOnly_Fails(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{StoreName: "My"}, nil, &stubExec{err: errors.New("access denied")})
|
||||
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{})
|
||||
if err == nil || !strings.Contains(err.Error(), "access denied") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWinCertStore_ValidateOnly_NilExec_Sentinel(t *testing.T) {
|
||||
c := &Connector{config: &Config{}}
|
||||
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWinCertStore_ValidateOnly_DefaultStore_LocalMachineMy(t *testing.T) {
|
||||
captured := ""
|
||||
exec := capture{out: "x", capt: &captured}
|
||||
c := NewWithExecutor(&Config{}, nil, &exec)
|
||||
c.ValidateOnly(context.Background(), target.DeploymentRequest{})
|
||||
// Backslash escaping in PowerShell-string + Go-string: the
|
||||
// final script literal contains "Cert:\\LocalMachine\\My" once
|
||||
// quoted via %q in fmt.Sprintf. Match against the doubled form.
|
||||
if !strings.Contains(captured, `LocalMachine\\My`) && !strings.Contains(captured, `LocalMachine\My`) {
|
||||
t.Errorf("default store path not in script: %q", captured)
|
||||
}
|
||||
}
|
||||
|
||||
type capture struct {
|
||||
out string
|
||||
capt *string
|
||||
}
|
||||
|
||||
func (c capture) Execute(_ context.Context, script string) (string, error) {
|
||||
*c.capt = script
|
||||
return c.out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user