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
11 changes: 11 additions & 0 deletions module/api_v1_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,17 @@ func (s *V1Store) initSchema() error {
created_at TEXT NOT NULL,
FOREIGN KEY (provider_id) REFERENCES iam_provider_configs(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS workflow_permissions (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL,
subject TEXT NOT NULL,
action TEXT NOT NULL DEFAULT 'read',
granted INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE
);
`
_, err := s.db.Exec(schema)
if err != nil {
Expand Down
40 changes: 37 additions & 3 deletions module/app_container.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package module

import (
"fmt"
"os"
"path/filepath"

"github.com/CrisisTextLine/modular"
)
Expand All @@ -27,6 +29,7 @@ type AppContainerModule struct {
current *AppDeployResult // current deployment state
previous *AppDeployResult // last known-good deployment for rollback
backend appContainerBackend
logger modular.Logger
}

// AppContainerSpec describes the desired state of an application container.
Expand Down Expand Up @@ -111,6 +114,8 @@ func (m *AppContainerModule) Name() string { return m.name }

// Init resolves the environment module and initialises the platform backend.
func (m *AppContainerModule) Init(app modular.Application) error {
m.logger = app.Logger()

envName, _ := m.config["environment"].(string)
if envName != "" {
svc, ok := app.SvcRegistry()[envName]
Expand All @@ -129,9 +134,20 @@ func (m *AppContainerModule) Init(app modular.Application) error {
return fmt.Errorf("app.container %q: environment %q is not a platform.kubernetes or platform.ecs module (got %T)", m.name, envName, svc)
}
} else {
// Default to kubernetes mock when no environment is specified.
m.backend = &k8sAppBackend{}
m.platformType = "kubernetes"
// No environment configured: choose backend based on whether a kubeconfig is available.
kubeconfigPath := kubeconfigPath()
if kubeconfigPath != "" {
m.logger.Info("app.container: no environment configured, using kubernetes backend from kubeconfig",
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log message says the module is "using kubernetes backend from kubeconfig", but the selected backend is still k8sAppBackend which (per the comment below) doesn’t use kubeconfig or talk to the real k8s API. This message is likely misleading; consider rewording it to something like "kubeconfig detected; defaulting to kubernetes backend" (or explicitly call out that this is the mock backend).

Suggested change
m.logger.Info("app.container: no environment configured, using kubernetes backend from kubeconfig",
m.logger.Info("app.container: kubeconfig detected; defaulting to kubernetes app backend (mock; does not use kubeconfig directly)",

Copilot uses AI. Check for mistakes.
"module", m.name, "kubeconfig", kubeconfigPath)
m.backend = &k8sAppBackend{}
m.platformType = "kubernetes"
} else {
m.logger.Warn("app.container: no environment configured and no kubeconfig found; defaulting to mock kubernetes backend",
"module", m.name,
"hint", "set 'environment' to a platform.kubernetes or platform.ecs module, or ensure KUBECONFIG / ~/.kube/config is present")
m.backend = &k8sAppBackend{}
m.platformType = "kubernetes"
Comment on lines +137 to +149
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log messages imply different behavior based on kubeconfig presence ("using kubernetes backend from kubeconfig" vs "defaulting to mock"), but both branches configure the same k8sAppBackend, which is explicitly a mock/no-API backend. This is misleading operationally; either adjust the messages to reflect reality (always mock) or actually switch behavior based on kubeconfig/environment (e.g., real Kubernetes backend when kubeconfig exists).

Copilot uses AI. Check for mistakes.
}
}

m.spec = m.parseSpec()
Expand Down Expand Up @@ -413,6 +429,24 @@ type K8sServicePortRef struct {
Number int `json:"number"`
}

// kubeconfigPath returns the path to the kubeconfig file if it exists, or an
// empty string. It checks KUBECONFIG env var first, then ~/.kube/config.
func kubeconfigPath() string {
if kc := os.Getenv("KUBECONFIG"); kc != "" {
kc = filepath.Clean(kc)
Comment on lines +432 to +436
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new kubeconfig detection logic isn’t covered by tests. Since module/app_container.go already has a dedicated test file, please add test cases for: (1) no environment + KUBECONFIG set to an existing file, (2) KUBECONFIG set to a non-existent path, and (3) KUBECONFIG containing multiple paths (SplitList behavior).

Copilot generated this review using guidance from organization custom instructions.
if _, err := os.Stat(kc); err == nil { //nolint:gosec // KUBECONFIG is a trusted env var
return kc
Comment on lines +433 to +438
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kubeconfigPath treats $KUBECONFIG as a single file path, but in common Kubernetes setups it can be a list of paths (separated by the OS list separator). In that case os.Stat will fail and this will incorrectly behave as if no kubeconfig exists. Consider using filepath.SplitList (or similar) and returning the first existing entry.

Suggested change
// empty string. It checks KUBECONFIG env var first, then ~/.kube/config.
func kubeconfigPath() string {
if kc := os.Getenv("KUBECONFIG"); kc != "" {
kc = filepath.Clean(kc)
if _, err := os.Stat(kc); err == nil { //nolint:gosec // KUBECONFIG is a trusted env var
return kc
// empty string. It checks KUBECONFIG env var first (which may be a list of paths),
// then ~/.kube/config.
func kubeconfigPath() string {
if kc := os.Getenv("KUBECONFIG"); kc != "" {
for _, entry := range filepath.SplitList(kc) {
entry = filepath.Clean(entry)
if entry == "" {
continue
}
if info, err := os.Stat(entry); err == nil && !info.IsDir() { //nolint:gosec // KUBECONFIG is a trusted env var
return entry
}

Copilot uses AI. Check for mistakes.
}
}
if home, err := os.UserHomeDir(); err == nil {
candidate := filepath.Join(home, ".kube", "config")
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}

// ─── Kubernetes backend ───────────────────────────────────────────────────────

// k8sAppBackend implements appContainerBackend for platform.kubernetes environments.
Expand Down
59 changes: 53 additions & 6 deletions module/pipeline_step_build_binary.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,26 @@ func (s *BuildBinaryStep) generateGoMod() string {
}

// generateMainGo returns the contents of the generated main.go file.
// The generated binary loads a workflow config (either embedded at compile-time
// or read from disk at runtime), builds the workflow engine, and runs it until
// SIGINT/SIGTERM is received.
func (s *BuildBinaryStep) generateMainGo() string {
var sb strings.Builder
sb.WriteString("package main\n\n")
sb.WriteString("import (\n")
sb.WriteString("\t_ \"embed\"\n")
if s.embedConfig {
sb.WriteString("\t_ \"embed\"\n")
}
sb.WriteString("\t\"context\"\n")
sb.WriteString("\t\"fmt\"\n")
sb.WriteString("\t\"log/slog\"\n")
sb.WriteString("\t\"os\"\n")
sb.WriteString("\t\"os/signal\"\n")
sb.WriteString("\t\"syscall\"\n")
sb.WriteString("\n")
sb.WriteString("\t\"github.com/CrisisTextLine/modular\"\n")
sb.WriteString("\tworkflow \"github.com/GoCodeAlone/workflow\"\n")
sb.WriteString("\t\"github.com/GoCodeAlone/workflow/config\"\n")
sb.WriteString(")\n\n")

if s.embedConfig {
Expand All @@ -180,17 +193,51 @@ func (s *BuildBinaryStep) generateMainGo() string {
}

sb.WriteString("func main() {\n")
sb.WriteString("\tlogger := slog.New(slog.NewTextHandler(os.Stdout, nil))\n\n")

if s.embedConfig {
sb.WriteString("\tif len(configYAML) == 0 {\n")
sb.WriteString("\t\tfmt.Fprintln(os.Stderr, \"embedded config is empty\")\n")
sb.WriteString("\t\tos.Exit(1)\n")
sb.WriteString("\t}\n")
sb.WriteString("\tfmt.Printf(\"Starting workflow app (config: %d bytes)\\n\", len(configYAML))\n")
sb.WriteString("\t}\n\n")
sb.WriteString("\tcfg, err := config.LoadFromString(string(configYAML))\n")
} else {
sb.WriteString("\tfmt.Println(\"Starting workflow app\")\n")
sb.WriteString("\tcfgFile := \"app.yaml\"\n")
sb.WriteString("\tif len(os.Args) > 1 {\n")
sb.WriteString("\t\tcfgFile = os.Args[1]\n")
sb.WriteString("\t}\n\n")
sb.WriteString("\tcfg, err := config.LoadFromFile(cfgFile)\n")
}
sb.WriteString("\t// TODO: wire up modular.NewStdApplication with embedded config\n")
sb.WriteString("\t_ = os.Args\n")
sb.WriteString("\tif err != nil {\n")
sb.WriteString("\t\tfmt.Fprintf(os.Stderr, \"parse config: %v\\n\", err)\n")
sb.WriteString("\t\tos.Exit(1)\n")
sb.WriteString("\t}\n\n")

Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When embed_config is true, the generated main.go uses config.LoadFromString, which does not populate WorkflowConfig.ConfigDir. The engine relies on cfg.ConfigDir for relative path resolution (e.g., pipeline steps/modules using _config_dir), so embedded configs can break relative paths. Consider setting cfg.ConfigDir to a sensible default (e.g., os.Getwd()) after LoadFromString, or embedding an explicit config dir alongside the YAML.

Suggested change
if s.embedConfig {
sb.WriteString("\tif cfg != nil && cfg.ConfigDir == \"\" {\n")
sb.WriteString("\t\twd, err := os.Getwd()\n")
sb.WriteString("\t\tif err != nil {\n")
sb.WriteString("\t\t\tfmt.Fprintf(os.Stderr, \"determine working directory: %v\\n\", err)\n")
sb.WriteString("\t\t\tos.Exit(1)\n")
sb.WriteString("\t\t}\n")
sb.WriteString("\t\tcfg.ConfigDir = wd\n")
sb.WriteString("\t}\n\n")
}

Copilot uses AI. Check for mistakes.
sb.WriteString("\tapp := modular.NewStdApplication(nil, logger)\n")
sb.WriteString("\tengine := workflow.NewStdEngine(app, logger)\n\n")

sb.WriteString("\tif err := engine.BuildFromConfig(cfg); err != nil {\n")
sb.WriteString("\t\tfmt.Fprintf(os.Stderr, \"build engine: %v\\n\", err)\n")
sb.WriteString("\t\tos.Exit(1)\n")
sb.WriteString("\t}\n\n")

sb.WriteString("\tctx, cancel := context.WithCancel(context.Background())\n")
sb.WriteString("\tdefer cancel()\n\n")

sb.WriteString("\tif err := engine.Start(ctx); err != nil {\n")
sb.WriteString("\t\tfmt.Fprintf(os.Stderr, \"start engine: %v\\n\", err)\n")
sb.WriteString("\t\tos.Exit(1)\n")
sb.WriteString("\t}\n\n")

sb.WriteString("\tsigCh := make(chan os.Signal, 1)\n")
sb.WriteString("\tsignal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)\n")
sb.WriteString("\t<-sigCh\n\n")

sb.WriteString("\tcancel()\n")
sb.WriteString("\tif err := engine.Stop(context.Background()); err != nil {\n")
sb.WriteString("\t\tfmt.Fprintf(os.Stderr, \"stop engine: %v\\n\", err)\n")
sb.WriteString("\t\tos.Exit(1)\n")
sb.WriteString("\t}\n")
sb.WriteString("}\n")
return sb.String()
}
Expand Down
27 changes: 23 additions & 4 deletions plugin/external/sdk/grpc_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import (
goplugin "github.com/GoCodeAlone/go-plugin"
pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/structpb"
)
Expand Down Expand Up @@ -364,8 +362,29 @@ func (s *grpcServer) GetConfigFragment(_ context.Context, _ *emptypb.Empty) (*pb

// --- Service RPCs ---

func (s *grpcServer) InvokeService(_ context.Context, _ *pb.InvokeServiceRequest) (*pb.InvokeServiceResponse, error) {
return nil, status.Error(codes.Unimplemented, "InvokeService not implemented")
// InvokeService routes a service method call to the registered module identified
// by handle_id. The module must implement ServiceInvoker; if it does not, an
// error is returned to the host.
func (s *grpcServer) InvokeService(_ context.Context, req *pb.InvokeServiceRequest) (*pb.InvokeServiceResponse, error) {
s.mu.RLock()
Comment on lines +368 to +369
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InvokeService ignores the incoming gRPC context (it’s named _), so deadlines/cancellation from the host can’t propagate to the plugin’s service method. To make service invocations cancellable, consider adding a context-aware optional interface (e.g., InvokeMethod(ctx, method, args)) and using it when implemented, falling back to the existing ServiceInvoker for compatibility.

Copilot uses AI. Check for mistakes.
inst, ok := s.modules[req.HandleId]
s.mu.RUnlock()
if !ok {
return &pb.InvokeServiceResponse{Error: fmt.Sprintf("unknown module handle: %s", req.HandleId)}, nil
}

invoker, ok := inst.(ServiceInvoker)
if !ok {
return &pb.InvokeServiceResponse{
Error: fmt.Sprintf("module handle %s does not implement ServiceInvoker", req.HandleId),
}, nil
}

result, err := invoker.InvokeMethod(req.Method, structToMap(req.Args))
Comment on lines +365 to +383
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InvokeService ignores the incoming RPC context, so cancellations/timeouts from the host will not propagate to the module implementation. Since ServiceInvoker is newly introduced, consider adding a context.Context parameter to ServiceInvoker.InvokeMethod (and passing ctx through here), or at least checking ctx.Done() before/while dispatching to allow early aborts.

Copilot uses AI. Check for mistakes.
if err != nil {
return &pb.InvokeServiceResponse{Error: err.Error()}, nil //nolint:nilerr // app error in response field
}
return &pb.InvokeServiceResponse{Result: mapToStruct(result)}, nil
Comment on lines +383 to +387
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InvokeService serializes the result via mapToStruct(result), but mapToStruct currently drops structpb.NewStruct conversion errors. If the result map contains non-structpb-compatible values, the response will silently lose data (or return a nil struct) with no error reported. Consider returning a conversion error to the host (e.g., make mapToStruct return (*structpb.Struct, error) and set InvokeServiceResponse.Error when conversion fails).

Copilot uses AI. Check for mistakes.
}

// --- Message delivery RPC ---
Expand Down
8 changes: 8 additions & 0 deletions plugin/external/sdk/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,11 @@ type ConfigProvider interface {
// ConfigFragment returns YAML config to merge into the host config.
ConfigFragment() ([]byte, error)
}

// ServiceInvoker is optionally implemented by ModuleInstance to handle
// service method invocations from the host. The host calls InvokeService
// with a method name and a map of arguments; the implementation dispatches
// to the appropriate logic and returns a result map.
type ServiceInvoker interface {
InvokeMethod(method string, args map[string]any) (map[string]any, error)
}
Loading