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
13 changes: 4 additions & 9 deletions go/apps/ctrl/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"connectrpc.com/connect"
"github.com/unkeyed/unkey/go/apps/ctrl/services/ctrl"
"github.com/unkeyed/unkey/go/apps/ctrl/services/openapi"
"github.com/unkeyed/unkey/go/apps/ctrl/services/version"
deployTLS "github.com/unkeyed/unkey/go/deploy/pkg/tls"
"github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect"
Expand Down Expand Up @@ -154,19 +155,13 @@ func Run(ctx context.Context, cfg Config) error {
return fmt.Errorf("unable to register deployment workflow: %w", err)
}

// Create the service implementations
ctrlSvc := ctrl.New(cfg.InstanceID, database)
versionSvc := version.New(database, hydraEngine, logger)

// Create the connect handler
mux := http.NewServeMux()

// Create the service handlers with interceptors
ctrlPath, ctrlHandler := ctrlv1connect.NewCtrlServiceHandler(ctrlSvc)
mux.Handle(ctrlPath, ctrlHandler)

versionPath, versionHandler := ctrlv1connect.NewVersionServiceHandler(versionSvc)
mux.Handle(versionPath, versionHandler)
mux.Handle(ctrlv1connect.NewCtrlServiceHandler(ctrl.New(cfg.InstanceID, database)))
mux.Handle(ctrlv1connect.NewVersionServiceHandler(version.New(database, hydraEngine, logger)))
mux.Handle(ctrlv1connect.NewOpenApiServiceHandler(openapi.New(database, logger)))

// Configure server
addr := fmt.Sprintf(":%d", cfg.HttpPort)
Expand Down
57 changes: 57 additions & 0 deletions go/apps/ctrl/services/openapi/convert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package openapi

import (
"github.com/oasdiff/oasdiff/checker"
"github.com/oasdiff/oasdiff/diff"
ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1"
"github.com/unkeyed/unkey/go/pkg/ptr"
)

func convertSummaryToProto(summary *diff.Summary) *ctrlv1.DiffSummary {
// Helper function to get counts safely
getCounts := func(name string) *ctrlv1.DiffCounts {
if details, exists := summary.Details[diff.DetailName(name)]; exists {
return &ctrlv1.DiffCounts{
Added: int32(details.Added),
Deleted: int32(details.Deleted),
Modified: int32(details.Modified),
}
}
return &ctrlv1.DiffCounts{Added: 0, Deleted: 0, Modified: 0}
}

return &ctrlv1.DiffSummary{
Diff: summary.Diff,
Details: &ctrlv1.DiffDetails{
Endpoints: getCounts("endpoints"),
Paths: getCounts("paths"),
Schemas: getCounts("schemas"),
},
}
}

func convertChangesToProto(changes checker.Changes) []*ctrlv1.ChangelogEntry {
localizer := checker.NewLocalizer("en")
result := make([]*ctrlv1.ChangelogEntry, len(changes))

for i, change := range changes {
level := int32(1) // INFO
switch change.GetLevel() {
case checker.WARN:
level = 2
case checker.ERR:
level = 3
}

result[i] = &ctrlv1.ChangelogEntry{
Id: change.GetId(),
Text: change.GetUncolorizedText(localizer),
Level: level,
Operation: change.GetOperation(),
Path: change.GetPath(),
OperationId: ptr.P(change.GetOperationId()),
}
}

return result
}
84 changes: 84 additions & 0 deletions go/apps/ctrl/services/openapi/get_diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package openapi

import (
"context"

"connectrpc.com/connect"
"github.com/getkin/kin-openapi/openapi3"
"github.com/oasdiff/oasdiff/checker"
"github.com/oasdiff/oasdiff/diff"
ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1"
"github.com/unkeyed/unkey/go/pkg/fault"
)

func (s *Service) GetOpenApiDiff(ctx context.Context, req *connect.Request[ctrlv1.GetOpenApiDiffRequest]) (*connect.Response[ctrlv1.GetOpenApiDiffResponse], error) {
// Load old version spec
oldSpec, err := s.loadVersionSpec(ctx, req.Msg.OldVersionId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, fault.Wrap(err,
fault.Internal("failed to load old version spec"),
fault.Public("Old version not found"),
))
}

// Load new version spec
newSpec, err := s.loadVersionSpec(ctx, req.Msg.NewVersionId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, fault.Wrap(err,
fault.Internal("failed to load new version spec"),
fault.Public("New version not found"),
))
}

// Parse OpenAPI specs
loader := openapi3.NewLoader()
loader.IsExternalRefsAllowed = true

s1, err := loader.LoadFromData([]byte(oldSpec))
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, fault.Wrap(err,
fault.Internal("failed to parse old OpenAPI spec"),
fault.Public("Invalid OpenAPI specification in old version"),
))
}

s2, err := loader.LoadFromData([]byte(newSpec))
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, fault.Wrap(err,
fault.Internal("failed to parse new OpenAPI spec"),
fault.Public("Invalid OpenAPI specification in new version"),
))
}

// Generate diff report
diffReport, err := diff.Get(&diff.Config{}, s1, s2)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fault.Wrap(err,
fault.Internal("failed to generate diff report"),
fault.Public("Failed to generate diff report"),
))
}

// Generate changelog using checker
config := checker.NewConfig(checker.GetAllChecks())
changes := checker.CheckBackwardCompatibility(
config,
diffReport,
&diff.OperationsSourcesMap{},
)

// Check if there are any breaking changes
hasBreakingChanges := false
for _, change := range changes {
if change.GetLevel() == checker.ERR {
hasBreakingChanges = true
break
}
}

return connect.NewResponse(&ctrlv1.GetOpenApiDiffResponse{
Summary: convertSummaryToProto(diffReport.GetSummary()),
HasBreakingChanges: hasBreakingChanges,
Changes: convertChangesToProto(changes),
}), nil
}
21 changes: 21 additions & 0 deletions go/apps/ctrl/services/openapi/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package openapi

import (
"github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect"
"github.com/unkeyed/unkey/go/pkg/db"
"github.com/unkeyed/unkey/go/pkg/otel/logging"
)

type Service struct {
ctrlv1connect.UnimplementedOpenApiServiceHandler
db db.Database
logger logging.Logger
}

func New(database db.Database, logger logging.Logger) *Service {
return &Service{
UnimplementedOpenApiServiceHandler: ctrlv1connect.UnimplementedOpenApiServiceHandler{},
db: database,
logger: logger,
}
}
23 changes: 23 additions & 0 deletions go/apps/ctrl/services/openapi/utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package openapi

import (
"context"

"github.com/unkeyed/unkey/go/pkg/db"
"github.com/unkeyed/unkey/go/pkg/fault"
)

func (s *Service) loadVersionSpec(ctx context.Context, versionID string) (string, error) {
version, err := db.Query.FindVersionById(ctx, s.db.RO(), versionID)
if err != nil {
return "", err
}

if !version.OpenapiSpec.Valid {
return "", fault.New("version has no OpenAPI spec stored",
fault.Public("OpenAPI specification not available for this version"),
)
}

return version.OpenapiSpec.String, nil
}
148 changes: 148 additions & 0 deletions go/apps/ctrl/services/version/deploy_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"fmt"
"io"
"net/http"
"strings"
"time"

Expand Down Expand Up @@ -524,6 +526,152 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro
return err
}

// Step 19.1: Health check container (using host port mapping)
err = hydra.StepVoid(ctx, "health-check-container", func(stepCtx context.Context) error {
if vmInfo.NetworkInfo == nil || len(vmInfo.NetworkInfo.PortMappings) == 0 {
return fmt.Errorf("no port mappings available for container health check")
}

// Find the port mapping for container port 8080
var hostPort int32
for _, portMapping := range vmInfo.NetworkInfo.PortMappings {
if portMapping.ContainerPort == 8080 {
hostPort = portMapping.HostPort
break
}
}

if hostPort == 0 {
return fmt.Errorf("no host port mapping found for container port 8080")
}

// Try multiple host addresses to reach the Docker host
// Prioritize Docker's magic domain names
hostAddresses := []string{
"host.docker.internal", // Docker Desktop (Windows/Mac) and some Linux setups
"gateway.docker.internal", // Docker gateway
"172.17.0.1", // Default Docker bridge gateway
"172.18.0.1", // Alternative Docker bridge
}

client := &http.Client{Timeout: 10 * time.Second}

for _, hostAddr := range hostAddresses {
healthURL := fmt.Sprintf("http://%s:%d/v1/liveness", hostAddr, hostPort)
w.logger.Info("trying container health check", "url", healthURL, "host_port", hostPort, "version_id", req.VersionID)

resp, err := client.Get(healthURL)
if err != nil {
w.logger.Warn("health check failed for host address", "error", err, "host_addr", hostAddr, "version_id", req.VersionID)
continue
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusOK {
w.logger.Info("container is healthy", "host_addr", hostAddr, "version_id", req.VersionID)
return nil
}

w.logger.Warn("health check returned non-200 status", "status", resp.StatusCode, "host_addr", hostAddr, "version_id", req.VersionID)
}

return fmt.Errorf("health check failed on all host addresses: %v", hostAddresses)
})
if err != nil {
w.logger.Error("container health check failed", "error", err, "version_id", req.VersionID)
// Don't fail the deployment, just skip OpenAPI scraping
}

// Step 19.2: Scrape OpenAPI spec from container (using host port mapping)
openapiSpec, err := hydra.Step(ctx, "scrape-openapi-spec", func(stepCtx context.Context) (string, error) {
if vmInfo.NetworkInfo == nil || len(vmInfo.NetworkInfo.PortMappings) == 0 {
w.logger.Warn("no port mappings available for OpenAPI scraping", "version_id", req.VersionID)
return "", nil
}

// Find the port mapping for container port 8080
var hostPort int32
for _, portMapping := range vmInfo.NetworkInfo.PortMappings {
if portMapping.ContainerPort == 8080 {
hostPort = portMapping.HostPort
break
}
}

if hostPort == 0 {
w.logger.Warn("no host port mapping found for container port 8080", "version_id", req.VersionID)
return "", nil
}

// Try multiple host addresses to reach the Docker host
hostAddresses := []string{
"host.docker.internal", // Docker Desktop (Windows/Mac) and some Linux setups
"gateway.docker.internal", // Docker gateway
"172.17.0.1", // Default Docker bridge gateway
"172.18.0.1", // Alternative Docker bridge
}

client := &http.Client{Timeout: 10 * time.Second}

for _, hostAddr := range hostAddresses {
openapiURL := fmt.Sprintf("http://%s:%d/openapi.yaml", hostAddr, hostPort)
w.logger.Info("trying to scrape OpenAPI spec", "url", openapiURL, "host_port", hostPort, "version_id", req.VersionID)

resp, err := client.Get(openapiURL)
if err != nil {
w.logger.Warn("OpenAPI scraping failed for host address", "error", err, "host_addr", hostAddr, "version_id", req.VersionID)
continue
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
w.logger.Warn("OpenAPI endpoint returned non-200 status", "status", resp.StatusCode, "host_addr", hostAddr, "version_id", req.VersionID)
continue
}

// Read the OpenAPI spec
specBytes, err := io.ReadAll(resp.Body)
if err != nil {
w.logger.Warn("failed to read OpenAPI spec response", "error", err, "host_addr", hostAddr, "version_id", req.VersionID)
continue
}

w.logger.Info("OpenAPI spec scraped successfully", "host_addr", hostAddr, "version_id", req.VersionID, "spec_size", len(specBytes))
return string(specBytes), nil
}

return "", fmt.Errorf("failed to scrape OpenAPI spec from all host addresses: %v", hostAddresses)
})
if err != nil {
w.logger.Error("failed to scrape OpenAPI spec", "error", err, "version_id", req.VersionID)
return err
}

// Step 19.3: Store OpenAPI spec in database
err = hydra.StepVoid(ctx, "store-openapi-spec", func(stepCtx context.Context) error {
if openapiSpec == "" {
w.logger.Info("no OpenAPI spec to store", "version_id", req.VersionID)
return nil
}

// Store in database
err := db.Query.UpdateVersionOpenApiSpec(stepCtx, w.db.RW(), db.UpdateVersionOpenApiSpecParams{
ID: req.VersionID,
OpenapiSpec: sql.NullString{String: openapiSpec, Valid: true},
})
if err != nil {
w.logger.Warn("failed to store OpenAPI spec in database", "error", err, "version_id", req.VersionID)
return nil // Don't fail the deployment
}

w.logger.Info("OpenAPI spec stored in database successfully", "version_id", req.VersionID, "spec_size", len(openapiSpec))
return nil
})
if err != nil {
w.logger.Error("failed to store OpenAPI spec", "error", err, "version_id", req.VersionID)
return err
}

// Step 20: Log completed
err = hydra.StepVoid(ctx, "log-completed", func(stepCtx context.Context) error {
return db.Query.InsertVersionStep(stepCtx, w.db.RW(), db.InsertVersionStepParams{
Expand Down
Loading