From cbd93f7972e338cd08de039e4c9a5b3701708314 Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 10 Sep 2025 16:11:16 +0200 Subject: [PATCH 01/20] chore(go): cleanup duplicate db code and fix file naming --- ...lk_acme_challenge_insert.sql_generated.go} | 0 ...=> bulk_acme_user_insert.sql_generated.go} | 0 ...ql.go => bulk_api_insert.sql_generated.go} | 0 ...=> bulk_audit_log_insert.sql_generated.go} | 0 ..._audit_log_target_insert.sql_generated.go} | 0 ...> bulk_deployment_insert.sql_generated.go} | 0 ...k_deployment_step_insert.sql_generated.go} | 0 ...go => bulk_domain_insert.sql_generated.go} | 0 ... => bulk_identity_insert.sql_generated.go} | 0 ...dentity_insert_ratelimit.sql_generated.go} | 0 ...lk_key_encryption_insert.sql_generated.go} | 0 ...ql.go => bulk_key_insert.sql_generated.go} | 0 ...ulk_key_insert_ratelimit.sql_generated.go} | 0 ...lk_key_permission_insert.sql_generated.go} | 0 ... => bulk_key_role_insert.sql_generated.go} | 0 ...o => bulk_keyring_insert.sql_generated.go} | 0 ...> bulk_permission_insert.sql_generated.go} | 0 ...o => bulk_project_insert.sql_generated.go} | 0 ...telimit_namespace_insert.sql_generated.go} | 0 ...atelimit_override_insert.sql_generated.go} | 0 ...l.go => bulk_role_insert.sql_generated.go} | 0 ...k_role_permission_insert.sql_generated.go} | 0 ...=> bulk_workspace_insert.sql_generated.go} | 0 go/pkg/db/plugins/bulk-insert/generator.go | 2 +- .../bulk_certificate_insert.sql_generated.go | 52 +++++++ .../db/bulk_gateway_upsert.sql_generated.go | 39 +++++ .../db/bulk_vm_upsert.sql_generated.go | 48 +++++++ go/pkg/partition/db/database.go | 133 +----------------- .../partition/db/handle_err_duplicate_key.go | 13 -- go/pkg/partition/db/handle_err_no_rows.go | 10 -- go/pkg/partition/db/interface.go | 34 ----- go/pkg/partition/db/querier_bulk_generated.go | 15 ++ go/pkg/partition/db/queries.go | 3 + go/pkg/partition/db/replica.go | 83 ----------- go/pkg/partition/db/sqlc.json | 17 +++ 35 files changed, 178 insertions(+), 271 deletions(-) rename go/pkg/db/{bulk_acme_challenge_insert.sql.go => bulk_acme_challenge_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_acme_user_insert.sql.go => bulk_acme_user_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_api_insert.sql.go => bulk_api_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_audit_log_insert.sql.go => bulk_audit_log_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_audit_log_target_insert.sql.go => bulk_audit_log_target_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_deployment_insert.sql.go => bulk_deployment_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_deployment_step_insert.sql.go => bulk_deployment_step_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_domain_insert.sql.go => bulk_domain_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_identity_insert.sql.go => bulk_identity_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_identity_insert_ratelimit.sql.go => bulk_identity_insert_ratelimit.sql_generated.go} (100%) rename go/pkg/db/{bulk_key_encryption_insert.sql.go => bulk_key_encryption_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_key_insert.sql.go => bulk_key_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_key_insert_ratelimit.sql.go => bulk_key_insert_ratelimit.sql_generated.go} (100%) rename go/pkg/db/{bulk_key_permission_insert.sql.go => bulk_key_permission_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_key_role_insert.sql.go => bulk_key_role_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_keyring_insert.sql.go => bulk_keyring_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_permission_insert.sql.go => bulk_permission_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_project_insert.sql.go => bulk_project_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_ratelimit_namespace_insert.sql.go => bulk_ratelimit_namespace_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_ratelimit_override_insert.sql.go => bulk_ratelimit_override_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_role_insert.sql.go => bulk_role_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_role_permission_insert.sql.go => bulk_role_permission_insert.sql_generated.go} (100%) rename go/pkg/db/{bulk_workspace_insert.sql.go => bulk_workspace_insert.sql_generated.go} (100%) create mode 100644 go/pkg/partition/db/bulk_certificate_insert.sql_generated.go create mode 100644 go/pkg/partition/db/bulk_gateway_upsert.sql_generated.go create mode 100644 go/pkg/partition/db/bulk_vm_upsert.sql_generated.go delete mode 100644 go/pkg/partition/db/handle_err_duplicate_key.go delete mode 100644 go/pkg/partition/db/handle_err_no_rows.go delete mode 100644 go/pkg/partition/db/interface.go create mode 100644 go/pkg/partition/db/querier_bulk_generated.go delete mode 100644 go/pkg/partition/db/replica.go diff --git a/go/pkg/db/bulk_acme_challenge_insert.sql.go b/go/pkg/db/bulk_acme_challenge_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_acme_challenge_insert.sql.go rename to go/pkg/db/bulk_acme_challenge_insert.sql_generated.go diff --git a/go/pkg/db/bulk_acme_user_insert.sql.go b/go/pkg/db/bulk_acme_user_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_acme_user_insert.sql.go rename to go/pkg/db/bulk_acme_user_insert.sql_generated.go diff --git a/go/pkg/db/bulk_api_insert.sql.go b/go/pkg/db/bulk_api_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_api_insert.sql.go rename to go/pkg/db/bulk_api_insert.sql_generated.go diff --git a/go/pkg/db/bulk_audit_log_insert.sql.go b/go/pkg/db/bulk_audit_log_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_audit_log_insert.sql.go rename to go/pkg/db/bulk_audit_log_insert.sql_generated.go diff --git a/go/pkg/db/bulk_audit_log_target_insert.sql.go b/go/pkg/db/bulk_audit_log_target_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_audit_log_target_insert.sql.go rename to go/pkg/db/bulk_audit_log_target_insert.sql_generated.go diff --git a/go/pkg/db/bulk_deployment_insert.sql.go b/go/pkg/db/bulk_deployment_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_deployment_insert.sql.go rename to go/pkg/db/bulk_deployment_insert.sql_generated.go diff --git a/go/pkg/db/bulk_deployment_step_insert.sql.go b/go/pkg/db/bulk_deployment_step_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_deployment_step_insert.sql.go rename to go/pkg/db/bulk_deployment_step_insert.sql_generated.go diff --git a/go/pkg/db/bulk_domain_insert.sql.go b/go/pkg/db/bulk_domain_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_domain_insert.sql.go rename to go/pkg/db/bulk_domain_insert.sql_generated.go diff --git a/go/pkg/db/bulk_identity_insert.sql.go b/go/pkg/db/bulk_identity_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_identity_insert.sql.go rename to go/pkg/db/bulk_identity_insert.sql_generated.go diff --git a/go/pkg/db/bulk_identity_insert_ratelimit.sql.go b/go/pkg/db/bulk_identity_insert_ratelimit.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_identity_insert_ratelimit.sql.go rename to go/pkg/db/bulk_identity_insert_ratelimit.sql_generated.go diff --git a/go/pkg/db/bulk_key_encryption_insert.sql.go b/go/pkg/db/bulk_key_encryption_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_key_encryption_insert.sql.go rename to go/pkg/db/bulk_key_encryption_insert.sql_generated.go diff --git a/go/pkg/db/bulk_key_insert.sql.go b/go/pkg/db/bulk_key_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_key_insert.sql.go rename to go/pkg/db/bulk_key_insert.sql_generated.go diff --git a/go/pkg/db/bulk_key_insert_ratelimit.sql.go b/go/pkg/db/bulk_key_insert_ratelimit.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_key_insert_ratelimit.sql.go rename to go/pkg/db/bulk_key_insert_ratelimit.sql_generated.go diff --git a/go/pkg/db/bulk_key_permission_insert.sql.go b/go/pkg/db/bulk_key_permission_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_key_permission_insert.sql.go rename to go/pkg/db/bulk_key_permission_insert.sql_generated.go diff --git a/go/pkg/db/bulk_key_role_insert.sql.go b/go/pkg/db/bulk_key_role_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_key_role_insert.sql.go rename to go/pkg/db/bulk_key_role_insert.sql_generated.go diff --git a/go/pkg/db/bulk_keyring_insert.sql.go b/go/pkg/db/bulk_keyring_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_keyring_insert.sql.go rename to go/pkg/db/bulk_keyring_insert.sql_generated.go diff --git a/go/pkg/db/bulk_permission_insert.sql.go b/go/pkg/db/bulk_permission_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_permission_insert.sql.go rename to go/pkg/db/bulk_permission_insert.sql_generated.go diff --git a/go/pkg/db/bulk_project_insert.sql.go b/go/pkg/db/bulk_project_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_project_insert.sql.go rename to go/pkg/db/bulk_project_insert.sql_generated.go diff --git a/go/pkg/db/bulk_ratelimit_namespace_insert.sql.go b/go/pkg/db/bulk_ratelimit_namespace_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_ratelimit_namespace_insert.sql.go rename to go/pkg/db/bulk_ratelimit_namespace_insert.sql_generated.go diff --git a/go/pkg/db/bulk_ratelimit_override_insert.sql.go b/go/pkg/db/bulk_ratelimit_override_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_ratelimit_override_insert.sql.go rename to go/pkg/db/bulk_ratelimit_override_insert.sql_generated.go diff --git a/go/pkg/db/bulk_role_insert.sql.go b/go/pkg/db/bulk_role_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_role_insert.sql.go rename to go/pkg/db/bulk_role_insert.sql_generated.go diff --git a/go/pkg/db/bulk_role_permission_insert.sql.go b/go/pkg/db/bulk_role_permission_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_role_permission_insert.sql.go rename to go/pkg/db/bulk_role_permission_insert.sql_generated.go diff --git a/go/pkg/db/bulk_workspace_insert.sql.go b/go/pkg/db/bulk_workspace_insert.sql_generated.go similarity index 100% rename from go/pkg/db/bulk_workspace_insert.sql.go rename to go/pkg/db/bulk_workspace_insert.sql_generated.go diff --git a/go/pkg/db/plugins/bulk-insert/generator.go b/go/pkg/db/plugins/bulk-insert/generator.go index 9f6c519fea..a105ec277c 100644 --- a/go/pkg/db/plugins/bulk-insert/generator.go +++ b/go/pkg/db/plugins/bulk-insert/generator.go @@ -145,7 +145,7 @@ func (g *Generator) generateBulkInsertFunction(query *plugin.Query) *plugin.File } // Generate filename - filename := fmt.Sprintf("bulk_%s.go", query.GetFilename()) + filename := fmt.Sprintf("bulk_%s_generated.go", query.GetFilename()) return &plugin.File{ Name: filename, diff --git a/go/pkg/partition/db/bulk_certificate_insert.sql_generated.go b/go/pkg/partition/db/bulk_certificate_insert.sql_generated.go new file mode 100644 index 0000000000..b4417fce73 --- /dev/null +++ b/go/pkg/partition/db/bulk_certificate_insert.sql_generated.go @@ -0,0 +1,52 @@ +// Code generated by sqlc bulk insert plugin. DO NOT EDIT. + +package db + +import ( + "context" + "fmt" + "strings" +) + +// bulkInsertCertificate is the base query for bulk insert +const bulkInsertCertificate = `INSERT INTO certificates (workspace_id, hostname, certificate, encrypted_private_key, created_at) VALUES %s ON DUPLICATE KEY UPDATE +workspace_id = VALUES(workspace_id), +hostname = VALUES(hostname), +certificate = VALUES(certificate), +encrypted_private_key = VALUES(encrypted_private_key), +updated_at = ?` + +// InsertCertificates performs bulk insert in a single query +func (q *BulkQueries) InsertCertificates(ctx context.Context, db DBTX, args []InsertCertificateParams) error { + + if len(args) == 0 { + return nil + } + + // Build the bulk insert query + valueClauses := make([]string, len(args)) + for i := range args { + valueClauses[i] = "(?, ?, ?, ?, ?)" + } + + bulkQuery := fmt.Sprintf(bulkInsertCertificate, strings.Join(valueClauses, ", ")) + + // Collect all arguments + var allArgs []any + for _, arg := range args { + allArgs = append(allArgs, arg.WorkspaceID) + allArgs = append(allArgs, arg.Hostname) + allArgs = append(allArgs, arg.Certificate) + allArgs = append(allArgs, arg.EncryptedPrivateKey) + allArgs = append(allArgs, arg.CreatedAt) + } + + // Add ON DUPLICATE KEY UPDATE parameters (only once, not per row) + if len(args) > 0 { + allArgs = append(allArgs, args[0].UpdatedAt) + } + + // Execute the bulk insert + _, err := db.ExecContext(ctx, bulkQuery, allArgs...) + return err +} diff --git a/go/pkg/partition/db/bulk_gateway_upsert.sql_generated.go b/go/pkg/partition/db/bulk_gateway_upsert.sql_generated.go new file mode 100644 index 0000000000..7f553cfd0c --- /dev/null +++ b/go/pkg/partition/db/bulk_gateway_upsert.sql_generated.go @@ -0,0 +1,39 @@ +// Code generated by sqlc bulk insert plugin. DO NOT EDIT. + +package db + +import ( + "context" + "fmt" + "strings" +) + +// bulkUpsertGateway is the base query for bulk insert +const bulkUpsertGateway = `INSERT INTO gateways (hostname, config) VALUES %s ON DUPLICATE KEY UPDATE config = VALUES(config)` + +// UpsertGateway performs bulk insert in a single query +func (q *BulkQueries) UpsertGateway(ctx context.Context, db DBTX, args []UpsertGatewayParams) error { + + if len(args) == 0 { + return nil + } + + // Build the bulk insert query + valueClauses := make([]string, len(args)) + for i := range args { + valueClauses[i] = "(?, ?)" + } + + bulkQuery := fmt.Sprintf(bulkUpsertGateway, strings.Join(valueClauses, ", ")) + + // Collect all arguments + var allArgs []any + for _, arg := range args { + allArgs = append(allArgs, arg.Hostname) + allArgs = append(allArgs, arg.Config) + } + + // Execute the bulk insert + _, err := db.ExecContext(ctx, bulkQuery, allArgs...) + return err +} diff --git a/go/pkg/partition/db/bulk_vm_upsert.sql_generated.go b/go/pkg/partition/db/bulk_vm_upsert.sql_generated.go new file mode 100644 index 0000000000..fd6e97a9a4 --- /dev/null +++ b/go/pkg/partition/db/bulk_vm_upsert.sql_generated.go @@ -0,0 +1,48 @@ +// Code generated by sqlc bulk insert plugin. DO NOT EDIT. + +package db + +import ( + "context" + "fmt" + "strings" +) + +// bulkUpsertVM is the base query for bulk insert +const bulkUpsertVM = `INSERT INTO vms (id, deployment_id, address, cpu_millicores, memory_mb, status) VALUES %s ON DUPLICATE KEY UPDATE + deployment_id = VALUES(deployment_id), + address = VALUES(address), + cpu_millicores = VALUES(cpu_millicores), + memory_mb = VALUES(memory_mb), + status = VALUES(status)` + +// UpsertVM performs bulk insert in a single query +func (q *BulkQueries) UpsertVM(ctx context.Context, db DBTX, args []UpsertVMParams) error { + + if len(args) == 0 { + return nil + } + + // Build the bulk insert query + valueClauses := make([]string, len(args)) + for i := range args { + valueClauses[i] = "(?, ?, ?, ?, ?, ?)" + } + + bulkQuery := fmt.Sprintf(bulkUpsertVM, strings.Join(valueClauses, ", ")) + + // Collect all arguments + var allArgs []any + for _, arg := range args { + allArgs = append(allArgs, arg.ID) + allArgs = append(allArgs, arg.DeploymentID) + allArgs = append(allArgs, arg.Address) + allArgs = append(allArgs, arg.CpuMillicores) + allArgs = append(allArgs, arg.MemoryMb) + allArgs = append(allArgs, arg.Status) + } + + // Execute the bulk insert + _, err := db.ExecContext(ctx, bulkQuery, allArgs...) + return err +} diff --git a/go/pkg/partition/db/database.go b/go/pkg/partition/db/database.go index f5ee516377..ef04e350eb 100644 --- a/go/pkg/partition/db/database.go +++ b/go/pkg/partition/db/database.go @@ -1,137 +1,10 @@ package db import ( - "database/sql" - "strings" - "time" - _ "github.com/go-sql-driver/mysql" - "github.com/unkeyed/unkey/go/pkg/fault" - "github.com/unkeyed/unkey/go/pkg/otel/logging" - "github.com/unkeyed/unkey/go/pkg/retry" + maindb "github.com/unkeyed/unkey/go/pkg/db" ) -// Config defines the parameters needed to establish partition database connections. -// It supports separate connections for read and write operations to allow -// for primary/replica setups across regions. -type Config struct { - // The primary DSN for your database. This must support both reads and writes. - PrimaryDSN string - - // The readonly replica will be used for most read queries. - // If omitted, the primary is used. - ReadOnlyDSN string - - // Logger for database-related operations - Logger logging.Logger -} - -// database implements the Database interface, providing access to database replicas -// and handling connection lifecycle. -type database struct { - writeReplica *Replica // Primary database connection used for write operations - readReplica *Replica // Connection used for read operations (may be same as primary) - logger logging.Logger // Logger for database operations -} - -func open(dsn string, logger logging.Logger) (db *sql.DB, err error) { - if !strings.Contains(dsn, "parseTime=true") { - return nil, fault.New("DSN must contain parseTime=true, see https://stackoverflow.com/questions/29341590/how-to-parse-time-from-database/29343013#29343013") - } - - err = retry.New( - retry.Attempts(3), - retry.Backoff(func(n int) time.Duration { - return time.Duration(n) * time.Second - }), - ).Do(func() error { - db, err = sql.Open("mysql", dsn) - if err != nil { - logger.Info("mysql not ready yet, retrying...", "error", err.Error()) - } - - return err - }) - - return db, err -} - -// New creates a new partition database instance with the provided configuration. -// It establishes connections to the primary database and optionally to a read-only replica. -// Returns an error if connections cannot be established or if DSNs are misconfigured. -func New(config Config) (*database, error) { - write, err := open(config.PrimaryDSN, config.Logger) - if err != nil { - return nil, fault.Wrap(err, fault.Internal("cannot open primary replica")) - } - - // Initialize primary replica - writeReplica := &Replica{ - db: write, - mode: "rw", - } - - // Initialize read replica with primary by default - readReplica := &Replica{ - db: write, - mode: "rw", - } - - // If a separate read-only DSN is provided, establish that connection - if config.ReadOnlyDSN != "" { - read, err := open(config.ReadOnlyDSN, config.Logger) - if err != nil { - return nil, fault.Wrap(err, fault.Internal("cannot open read replica")) - } - - readReplica = &Replica{ - db: read, - mode: "ro", - } - config.Logger.Info("partition database configured with separate read replica") - } else { - config.Logger.Info("partition database configured without separate read replica, using primary for reads") - } - - return &database{ - writeReplica: writeReplica, - readReplica: readReplica, - logger: config.Logger, - }, nil -} - -// RW returns the write replica for performing database write operations. -func (d *database) RW() *Replica { - return d.writeReplica -} - -// RO returns the read replica for performing database read operations. -// If no dedicated read replica is configured, it returns the write replica. -func (d *database) RO() *Replica { - if d.readReplica != nil { - return d.readReplica - } - return d.writeReplica -} - -// Close properly closes all database connections. -// This should be called when the application is shutting down. -func (d *database) Close() error { - // Close the write replica connection - writeCloseErr := d.writeReplica.db.Close() - - // Only close the read replica if it's a separate connection - if d.readReplica != nil { - readCloseErr := d.readReplica.db.Close() - if readCloseErr != nil { - return fault.Wrap(readCloseErr) - } - } - - // Return any write replica close error - if writeCloseErr != nil { - return fault.Wrap(writeCloseErr) - } - return nil -} +// Type aliases to use main db interfaces +type DBTX = maindb.DBTX diff --git a/go/pkg/partition/db/handle_err_duplicate_key.go b/go/pkg/partition/db/handle_err_duplicate_key.go deleted file mode 100644 index c72bbeace5..0000000000 --- a/go/pkg/partition/db/handle_err_duplicate_key.go +++ /dev/null @@ -1,13 +0,0 @@ -package db - -import ( - "github.com/go-sql-driver/mysql" -) - -func IsDuplicateKeyError(err error) bool { - if mysqlErr, ok := err.(*mysql.MySQLError); ok && mysqlErr.Number == 1062 { - return true - } - - return false -} diff --git a/go/pkg/partition/db/handle_err_no_rows.go b/go/pkg/partition/db/handle_err_no_rows.go deleted file mode 100644 index aae1d973e1..0000000000 --- a/go/pkg/partition/db/handle_err_no_rows.go +++ /dev/null @@ -1,10 +0,0 @@ -package db - -import ( - "database/sql" - "errors" -) - -func IsNotFound(err error) bool { - return errors.Is(err, sql.ErrNoRows) -} diff --git a/go/pkg/partition/db/interface.go b/go/pkg/partition/db/interface.go deleted file mode 100644 index 211c4f2e00..0000000000 --- a/go/pkg/partition/db/interface.go +++ /dev/null @@ -1,34 +0,0 @@ -package db - -import ( - "context" - "database/sql" -) - -// Database defines the interface for partition database operations, providing access -// to read and write replicas and the ability to close connections. -type Database interface { - // RW returns the write (primary) replica for write operations - RW() *Replica - - // RO returns the read replica for read operations - // If no read replica is configured, it returns the write replica - RO() *Replica - - // Close properly terminates all database connections - Close() error -} - -// DBTX is an interface that abstracts database operations for both -// direct connections and transactions. It allows query methods to work -// with either a database or transaction, making transaction handling more -// flexible. -// -// This interface is implemented by both sql.DB and sql.Tx, as well as -// the custom Replica type in this package. -type DBTX interface { - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row -} diff --git a/go/pkg/partition/db/querier_bulk_generated.go b/go/pkg/partition/db/querier_bulk_generated.go new file mode 100644 index 0000000000..877a6f4207 --- /dev/null +++ b/go/pkg/partition/db/querier_bulk_generated.go @@ -0,0 +1,15 @@ +// Code generated by sqlc bulk insert plugin. DO NOT EDIT. + +package db + +import "context" + +// BulkQuerier contains bulk insert methods. +type BulkQuerier interface { + InsertCertificates(ctx context.Context, db DBTX, args []InsertCertificateParams) error + UpsertGateway(ctx context.Context, db DBTX, args []UpsertGatewayParams) error + UpsertVM(ctx context.Context, db DBTX, args []UpsertVMParams) error +} + +// Ensure Queries implements BulkQuerier +var _ BulkQuerier = (*BulkQueries)(nil) diff --git a/go/pkg/partition/db/queries.go b/go/pkg/partition/db/queries.go index eb3eea5bb7..ee296fac82 100644 --- a/go/pkg/partition/db/queries.go +++ b/go/pkg/partition/db/queries.go @@ -1,6 +1,7 @@ package db type Queries struct{} +type BulkQueries struct{} // Query provides access to the generated database queries defined in the SQL files // @@ -26,3 +27,5 @@ type Queries struct{} // The Query object contains all the database operations defined in the SQL files // and automatically generated by sqlc. var Query Querier = &Queries{} + +var BulkQuery BulkQuerier = &BulkQueries{} diff --git a/go/pkg/partition/db/replica.go b/go/pkg/partition/db/replica.go deleted file mode 100644 index 77e5ef53b0..0000000000 --- a/go/pkg/partition/db/replica.go +++ /dev/null @@ -1,83 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "fmt" - - "github.com/unkeyed/unkey/go/pkg/fault" -) - -// Replica represents a database connection (either primary or read-only) -// and provides methods for executing queries and transactions. -type Replica struct { - db *sql.DB - mode string // "rw" for read-write, "ro" for read-only -} - -// DB returns the underlying sql.DB connection. -// This is useful when you need to access the raw database connection. -func (r *Replica) DB() *sql.DB { - return r.db -} - -// Mode returns the mode of this replica ("rw" or "ro"). -func (r *Replica) Mode() string { - return r.mode -} - -// ExecContext executes a query without returning any rows. -// This is typically used for INSERT, UPDATE, DELETE, and DDL statements. -func (r *Replica) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { - result, err := r.db.ExecContext(ctx, query, args...) - if err != nil { - return nil, fault.Wrap(err, fault.Internal(fmt.Sprintf("failed to execute query on %s replica", r.mode))) - } - return result, nil -} - -// PrepareContext creates a prepared statement for later queries or executions. -func (r *Replica) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { - stmt, err := r.db.PrepareContext(ctx, query) - if err != nil { - return nil, fault.Wrap(err, fault.Internal(fmt.Sprintf("failed to prepare statement on %s replica", r.mode))) - } - return stmt, nil -} - -// QueryContext executes a query that returns rows. -func (r *Replica) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { - rows, err := r.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, fault.Wrap(err, fault.Internal(fmt.Sprintf("failed to execute query on %s replica", r.mode))) - } - return rows, nil -} - -// QueryRowContext executes a query that is expected to return at most one row. -func (r *Replica) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { - return r.db.QueryRowContext(ctx, query, args...) -} - -// BeginTx starts a transaction with the given options. -// Note: Transactions should only be used on write replicas. -func (r *Replica) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) { - if r.mode == "ro" { - return nil, fault.New("cannot start transaction on read-only replica") - } - - tx, err := r.db.BeginTx(ctx, opts) - if err != nil { - return nil, fault.Wrap(err, fault.Internal("failed to begin transaction")) - } - return tx, nil -} - -// Ping verifies a connection to the database is still alive. -func (r *Replica) Ping() error { - err := r.db.Ping() - if err != nil { - return fault.Wrap(err, fault.Internal(fmt.Sprintf("failed to ping %s replica", r.mode))) - } - return nil -} diff --git a/go/pkg/partition/db/sqlc.json b/go/pkg/partition/db/sqlc.json index 5c263f7988..0b42800951 100644 --- a/go/pkg/partition/db/sqlc.json +++ b/go/pkg/partition/db/sqlc.json @@ -5,6 +5,15 @@ "engine": "mysql", "queries": "./queries", "schema": "schema.sql", + "codegen": [ + { + "out": ".", + "plugin": "bulk-insert", + "options": { + "emit_methods_with_db_argument": true + } + } + ], "gen": { "go": { "package": "db", @@ -24,5 +33,13 @@ } } } + ], + "plugins": [ + { + "name": "bulk-insert", + "process": { + "cmd": "../../db/plugins/dist/bulk-insert" + } + } ] } From 761c4ac711f9fd5e3971973f259364db0bfa8f86 Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 10 Sep 2025 16:12:07 +0200 Subject: [PATCH 02/20] feat(ctrl): add inital backend k8s docker fallback --- go/apps/ctrl/config.go | 2 + go/apps/ctrl/run.go | 8 +- go/apps/ctrl/services/deployment/backend.go | 108 ++++++ .../services/deployment/create_deployment.go | 10 +- .../services/deployment/deploy_workflow.go | 342 +++++++++++------- .../services/deployment/fallbacks/docker.go | 283 +++++++++++++++ .../deployment/fallbacks/interface.go | 36 ++ .../ctrl/services/deployment/fallbacks/k8s.go | 292 +++++++++++++++ go/apps/gw/run.go | 3 +- .../gw/services/certmanager/certmanager.go | 5 +- go/apps/gw/services/certmanager/interface.go | 2 +- go/apps/gw/services/routing/interface.go | 5 +- go/apps/gw/services/routing/service.go | 19 +- go/cmd/ctrl/main.go | 4 +- go/cmd/deploy/build_docker.go | 51 ++- go/cmd/gw/main.go | 2 +- go/go.mod | 38 ++ go/go.sum | 82 +++++ 18 files changed, 1137 insertions(+), 155 deletions(-) create mode 100644 go/apps/ctrl/services/deployment/backend.go create mode 100644 go/apps/ctrl/services/deployment/fallbacks/docker.go create mode 100644 go/apps/ctrl/services/deployment/fallbacks/interface.go create mode 100644 go/apps/ctrl/services/deployment/fallbacks/k8s.go diff --git a/go/apps/ctrl/config.go b/go/apps/ctrl/config.go index 7feeb45494..6d45849f26 100644 --- a/go/apps/ctrl/config.go +++ b/go/apps/ctrl/config.go @@ -50,6 +50,8 @@ type Config struct { // MetaldAddress is the full URL of the metald service for VM operations (e.g., "https://metald.example.com:8080") MetaldAddress string + MetalDFallback string // fallback to either k8's pod or docker, this skips calling metald + // SPIFFESocketPath is the path to the SPIFFE agent socket for mTLS authentication SPIFFESocketPath string diff --git a/go/apps/ctrl/run.go b/go/apps/ctrl/run.go index d974b0cdc7..2d32ad754b 100644 --- a/go/apps/ctrl/run.go +++ b/go/apps/ctrl/run.go @@ -186,7 +186,13 @@ func Run(ctx context.Context, cfg Config) error { logger.Info("metald client configured", "address", cfg.MetaldAddress, "auth_mode", authMode) // Register deployment workflow with Hydra worker - deployWorkflow := deployment.NewDeployWorkflow(database, partitionDB, logger, metaldClient) + deployWorkflow := deployment.NewDeployWorkflow(deployment.DeployWorkflowConfig{ + Logger: logger, + DB: database, + PartitionDB: partitionDB, + MetalDFallback: cfg.MetalDFallback, + MetalD: metaldClient, + }) err = hydra.RegisterWorkflow(hydraWorker, deployWorkflow) if err != nil { return fmt.Errorf("unable to register deployment workflow: %w", err) diff --git a/go/apps/ctrl/services/deployment/backend.go b/go/apps/ctrl/services/deployment/backend.go new file mode 100644 index 0000000000..f1d2f0bd77 --- /dev/null +++ b/go/apps/ctrl/services/deployment/backend.go @@ -0,0 +1,108 @@ +package deployment + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + "github.com/unkeyed/unkey/go/apps/ctrl/services/deployment/fallbacks" + metaldv1 "github.com/unkeyed/unkey/go/gen/proto/metald/v1" + "github.com/unkeyed/unkey/go/gen/proto/metald/v1/metaldv1connect" + "github.com/unkeyed/unkey/go/pkg/otel/logging" +) + +// DeploymentBackend provides a unified interface for deployment operations +type DeploymentBackend interface { + CreateDeployment(ctx context.Context, req *metaldv1.CreateDeploymentRequest) (*metaldv1.CreateDeploymentResponse, error) + GetDeployment(ctx context.Context, deploymentID string) ([]*metaldv1.GetDeploymentResponse_Vm, error) +} + +// MetalDBackend implements DeploymentBackend using the metalD service +type MetalDBackend struct { + client metaldv1connect.VmServiceClient + logger logging.Logger +} + +func NewMetalDBackend(client metaldv1connect.VmServiceClient, logger logging.Logger) *MetalDBackend { + return &MetalDBackend{ + client: client, + logger: logger, + } +} + +func (m *MetalDBackend) CreateDeployment(ctx context.Context, req *metaldv1.CreateDeploymentRequest) (*metaldv1.CreateDeploymentResponse, error) { + resp, err := m.client.CreateDeployment(ctx, connect.NewRequest(req)) + if err != nil { + return nil, fmt.Errorf("metald CreateDeployment failed: %w", err) + } + return resp.Msg, nil +} + +func (m *MetalDBackend) GetDeployment(ctx context.Context, deploymentID string) ([]*metaldv1.GetDeploymentResponse_Vm, error) { + resp, err := m.client.GetDeployment(ctx, connect.NewRequest(&metaldv1.GetDeploymentRequest{ + DeploymentId: deploymentID, + })) + if err != nil { + return nil, fmt.Errorf("metald GetDeployment failed: %w", err) + } + return resp.Msg.GetVms(), nil +} + +// FallbackBackend wraps a fallback backend to implement the unified DeploymentBackend interface +type FallbackBackend struct { + backend fallbacks.DeploymentBackend + logger logging.Logger +} + +func NewFallbackBackend(backendType string, logger logging.Logger) (*FallbackBackend, error) { + backend, err := fallbacks.NewBackend(backendType, logger) + if err != nil { + return nil, err + } + return &FallbackBackend{ + backend: backend, + logger: logger, + }, nil +} + +func (f *FallbackBackend) CreateDeployment(ctx context.Context, req *metaldv1.CreateDeploymentRequest) (*metaldv1.CreateDeploymentResponse, error) { + deployment := req.GetDeployment() + if deployment == nil { + return nil, fmt.Errorf("deployment request is nil") + } + + vmIDs, err := f.backend.CreateDeployment(ctx, + deployment.GetDeploymentId(), + deployment.GetImage(), + int32(deployment.GetVmCount())) + if err != nil { + return nil, fmt.Errorf("fallback CreateDeployment failed: %w", err) + } + + return &metaldv1.CreateDeploymentResponse{ + VmIds: vmIDs, + }, nil +} + +func (f *FallbackBackend) GetDeployment(ctx context.Context, deploymentID string) ([]*metaldv1.GetDeploymentResponse_Vm, error) { + vms, err := f.backend.GetDeploymentStatus(ctx, deploymentID) + if err != nil { + return nil, fmt.Errorf("fallback GetDeploymentStatus failed: %w", err) + } + return vms, nil +} + +// NewDeploymentBackend creates the appropriate backend based on configuration +func NewDeploymentBackend(metalDClient metaldv1connect.VmServiceClient, fallbackType string, logger logging.Logger) (DeploymentBackend, error) { + if fallbackType != "" { + logger.Info("using fallback deployment backend", "type", fallbackType) + return NewFallbackBackend(fallbackType, logger) + } + + if metalDClient == nil { + return nil, fmt.Errorf("no deployment backend available: metalD client is nil and no fallback configured") + } + + logger.Info("using metalD deployment backend") + return NewMetalDBackend(metalDClient, logger), nil +} diff --git a/go/apps/ctrl/services/deployment/create_deployment.go b/go/apps/ctrl/services/deployment/create_deployment.go index a5fc129482..167d6deb5c 100644 --- a/go/apps/ctrl/services/deployment/create_deployment.go +++ b/go/apps/ctrl/services/deployment/create_deployment.go @@ -26,7 +26,6 @@ func (s *Service) CreateDeployment( ctx context.Context, req *connect.Request[ctrlv1.CreateDeploymentRequest], ) (*connect.Response[ctrlv1.CreateDeploymentResponse], error) { - // Validate workspace exists _, err := db.Query.FindWorkspaceByID(ctx, s.db.RO(), req.Msg.GetWorkspaceId()) if err != nil { @@ -72,11 +71,15 @@ func (s *Service) CreateDeployment( return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("git_commit_timestamp must be Unix epoch milliseconds, got %d (appears to be seconds format)", timestamp)) } + // Also reject future timestamps more than 1 hour ahead (likely invalid) maxValidTimestamp := time.Now().Add(1 * time.Hour).UnixMilli() if timestamp > maxValidTimestamp { - return nil, connect.NewError(connect.CodeInvalidArgument, - fmt.Errorf("git_commit_timestamp %d is too far in the future (must be Unix epoch milliseconds)", timestamp)) + return nil, + connect.NewError( + connect.CodeInvalidArgument, + fmt.Errorf("git_commit_timestamp %d is too far in the future (must be Unix epoch milliseconds)", timestamp), + ) } } @@ -138,6 +141,7 @@ func (s *Service) CreateDeployment( hydra.WithTimeout(25*time.Minute), hydra.WithRetryBackoff(1*time.Minute), ) + if err != nil { s.logger.Error("failed to start deployment workflow", "deployment_id", deploymentID, diff --git a/go/apps/ctrl/services/deployment/deploy_workflow.go b/go/apps/ctrl/services/deployment/deploy_workflow.go index 5eff382a6a..bc6a94647c 100644 --- a/go/apps/ctrl/services/deployment/deploy_workflow.go +++ b/go/apps/ctrl/services/deployment/deploy_workflow.go @@ -7,7 +7,6 @@ import ( "strings" "time" - "connectrpc.com/connect" metaldv1 "github.com/unkeyed/unkey/go/gen/proto/metald/v1" "github.com/unkeyed/unkey/go/gen/proto/metald/v1/metaldv1connect" partitionv1 "github.com/unkeyed/unkey/go/gen/proto/partition/v1" @@ -17,26 +16,41 @@ import ( "github.com/unkeyed/unkey/go/pkg/otel/logging" partitiondb "github.com/unkeyed/unkey/go/pkg/partition/db" "github.com/unkeyed/unkey/go/pkg/uid" - "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/encoding/protojson" ) // DeployWorkflow orchestrates the complete build and deployment process using Hydra type DeployWorkflow struct { - db db.Database - partitionDB db.Database - logger logging.Logger - metaldClient metaldv1connect.VmServiceClient + db db.Database + partitionDB db.Database + logger logging.Logger + deploymentBackend DeploymentBackend +} + +type DeployWorkflowConfig struct { + Logger logging.Logger + DB db.Database + PartitionDB db.Database + MetalD metaldv1connect.VmServiceClient + MetalDFallback string } // NewDeployWorkflow creates a new deploy workflow instance -func NewDeployWorkflow(database db.Database, partitionDB db.Database, - logger logging.Logger, metaldClient metaldv1connect.VmServiceClient, -) *DeployWorkflow { +func NewDeployWorkflow(cfg DeployWorkflowConfig) *DeployWorkflow { + // Create the appropriate deployment backend + deploymentBackend, err := NewDeploymentBackend(cfg.MetalD, cfg.MetalDFallback, cfg.Logger) + if err != nil { + // Log error but continue - workflow will fail when trying to use the backend + cfg.Logger.Error("failed to initialize deployment backend", + "error", err, + "fallback", cfg.MetalDFallback) + } + return &DeployWorkflow{ - db: database, - partitionDB: partitionDB, - logger: logger, - metaldClient: metaldClient, + db: cfg.DB, + partitionDB: cfg.PartitionDB, + logger: cfg.Logger, + deploymentBackend: deploymentBackend, } } @@ -71,7 +85,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro "project_id", req.ProjectID, "hostname", req.Hostname) - // Step 2: Log deployment pending + // Log deployment pending err := hydra.StepVoid(ctx, "log-deployment-pending", func(stepCtx context.Context) error { return db.Query.InsertDeploymentStep(stepCtx, w.db.RW(), db.InsertDeploymentStepParams{ WorkspaceID: req.WorkspaceID, @@ -87,7 +101,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return err } - // Step 4: Update version status to building + // Update version status to building _, err = hydra.Step(ctx, "update-version-building", func(stepCtx context.Context) (*struct{}, error) { w.logger.Info("updating deployment status to building", "deployment_id", req.DeploymentID) updateErr := db.Query.UpdateDeploymentStatus(stepCtx, w.db.RW(), db.UpdateDeploymentStatusParams{ @@ -109,8 +123,12 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro deployment, err := hydra.Step(ctx, "metald-create-deployment", func(stepCtx context.Context) (*metaldv1.CreateDeploymentResponse, error) { w.logger.Info("creating deployment", "deployment_id", req.DeploymentID, "docker_image", req.DockerImage, "workspace_id", req.WorkspaceID, "project_id", req.ProjectID) - // Call metald CreateDeployment - resp, err := w.metaldClient.CreateDeployment(stepCtx, connect.NewRequest(&metaldv1.CreateDeploymentRequest{ + if w.deploymentBackend == nil { + return nil, fmt.Errorf("deployment backend not initialized") + } + + // Create deployment request + deploymentReq := &metaldv1.CreateDeploymentRequest{ Deployment: &metaldv1.DeploymentRequest{ DeploymentId: req.DeploymentID, Image: req.DockerImage, @@ -118,13 +136,15 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro Cpu: 1, MemorySizeMib: 1024, }, - })) + } + + resp, err := w.deploymentBackend.CreateDeployment(stepCtx, deploymentReq) if err != nil { - w.logger.Error("metald CreateDeployment call failed", "error", err, "docker_image", req.DockerImage) + w.logger.Error("CreateDeployment failed", "error", err, "docker_image", req.DockerImage) return nil, fmt.Errorf("failed to create deployment: %w", err) } - return resp.Msg, nil + return resp, nil }) if err != nil { w.logger.Error("Deployment failed", "error", err, "deployment_id", req.DeploymentID) @@ -133,7 +153,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro w.logger.Info("Deployment created", "vm_ids", deployment.GetVmIds()) - // Step 12: Update version status to deploying + // Update version status to deploying _, err = hydra.Step(ctx, "update-version-deploying", func(stepCtx context.Context) (*struct{}, error) { w.logger.Info("starting deployment", "deployment_id", req.DeploymentID) @@ -153,24 +173,24 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro } createdVMs, err := hydra.Step(ctx, "polling deployment prepare", func(stepCtx context.Context) ([]*metaldv1.GetDeploymentResponse_Vm, error) { - instances := make(map[string]*metaldv1.GetDeploymentResponse_Vm) for i := range 300 { time.Sleep(time.Second) - w.logger.Info("Polling deployment", "i", i) - resp, err := w.metaldClient.GetDeployment(stepCtx, connect.NewRequest(&metaldv1.GetDeploymentRequest{ - DeploymentId: req.DeploymentID, - })) + + if w.deploymentBackend == nil { + return nil, fmt.Errorf("deployment backend not initialized") + } + + vms, err := w.deploymentBackend.GetDeployment(stepCtx, req.DeploymentID) if err != nil { - w.logger.Error("metald GetDeployment call failed", "error", err, "deployment_id", req.DeploymentID) + w.logger.Error("GetDeployment failed", "error", err, "deployment_id", req.DeploymentID) return nil, fmt.Errorf("failed to get deployment: %w", err) - } allReady := true - for _, instance := range resp.Msg.GetVms() { + for _, instance := range vms { known, ok := instances[instance.Id] if !ok || known.State != instance.State { if err := partitiondb.Query.UpsertVM(stepCtx, w.partitionDB.RW(), partitiondb.UpsertVMParams{ @@ -184,6 +204,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro w.logger.Error("failed to upsert VM", "error", err, "vm_id", instance.Id) return nil, fmt.Errorf("failed to upsert VM %s: %w", instance.Id, err) } + instances[instance.Id] = instance if instance.State != metaldv1.VmState_VM_STATE_RUNNING { allReady = false @@ -193,10 +214,10 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro } if allReady { - return resp.Msg.GetVms(), nil + return vms, nil } - } + return nil, fmt.Errorf("deployment never became ready") }) if err != nil { @@ -204,77 +225,19 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return err } - err = hydra.StepVoid(ctx, "create-gateway-config", func(stepCtx context.Context) error { - // Only create gateway config if hostname is provided - if req.Hostname == "" { - w.logger.Info("no hostname provided, skipping gateway configuration") - return nil - } - - w.logger.Info("creating gateway configuration", "hostname", req.Hostname, "deployment_id", req.DeploymentID) - - // Validate partition DB connection - if w.partitionDB == nil { - w.logger.Error("CRITICAL: partition database not initialized for gateway config") - return fmt.Errorf("partition database not initialized for gateway config") - } - - // Create VM protobuf objects for gateway config - - gatewayConfig := &partitionv1.GatewayConfig{ - Deployment: &partitionv1.Deployment{ - Id: req.DeploymentID, - IsEnabled: true, - }, - Vms: make([]*partitionv1.VM, len(createdVMs)), - } - for i, vm := range createdVMs { - gatewayConfig.Vms[i] = &partitionv1.VM{ - Id: vm.Id, - } - } - - // Only add AuthConfig if we have a KeyspaceID - if req.KeyspaceID != "" { - gatewayConfig.AuthConfig = &partitionv1.AuthConfig{ - - KeyAuthId: req.KeyspaceID, - } - } + // Generate all domains (custom + auto-generated) + allDomains, err := hydra.Step(ctx, "generate-all-domains", func(stepCtx context.Context) ([]string, error) { + w.logger.Info("generating all domains for deployment", "deployment_id", req.DeploymentID) - // Marshal protobuf to bytes - configBytes, err := proto.Marshal(gatewayConfig) - if err != nil { - w.logger.Error("failed to marshal gateway config", "error", err) - return fmt.Errorf("failed to marshal gateway config: %w", err) - } + var domains []string - // Insert gateway config into partition database - params := partitiondb.UpsertGatewayParams{ - Hostname: req.Hostname, - Config: configBytes, + // Add custom hostname if provided + if req.Hostname != "" { + domains = append(domains, req.Hostname) + w.logger.Info("added custom hostname", "hostname", req.Hostname) } - if err := partitiondb.Query.UpsertGateway(stepCtx, w.partitionDB.RW(), params); err != nil { - w.logger.Error("failed to upsert gateway config", "error", err, "hostname", req.Hostname) - return fmt.Errorf("failed to upsert gateway config: %w", err) - } - w.logger.Info("gateway configuration created successfully", "hostname", req.Hostname) - return nil - }) - if err != nil { - w.logger.Error("failed to create gateway configuration", "error", err, "hostname", req.Hostname) - return err - } - - // Step 19: Assign domains (create route entries) - assignedHostnames, err := hydra.Step(ctx, "assign-domains", func(stepCtx context.Context) ([]string, error) { - w.logger.Info("assigning domains to version", "deployment_id", req.DeploymentID) - - var hostnames []string - - // Generate primary hostname for this deployment - // Use Git info for hostname generation + // Generate auto-generated hostname for this deployment gitInfo := git.GetInfo() branch := "main" // Default branch identifier := req.DeploymentID // Use full version ID as identifier @@ -288,59 +251,105 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro } } - // Generate hostnames: branch-identifier-workspace.unkey.app - // Replace underscores with dashes for valid hostname format + // Generate primary hostname: branch-identifier-workspace.unkey.app cleanIdentifier := strings.ReplaceAll(identifier, "_", "-") - primaryHostname := fmt.Sprintf("%s-%s-%s.unkey.app", branch, cleanIdentifier, req.WorkspaceID) + cleanBranch := strings.ReplaceAll(branch, "/", "-") + autoGeneratedHostname := fmt.Sprintf("%s-%s-%s.unkey.app", cleanBranch, cleanIdentifier, req.WorkspaceID) + domains = append(domains, autoGeneratedHostname) - // Create domain entry for primary hostname - domainID := uid.New("domain") - insertErr := db.Query.InsertDomain(stepCtx, w.db.RW(), db.InsertDomainParams{ - ID: domainID, - WorkspaceID: req.WorkspaceID, - ProjectID: sql.NullString{Valid: true, String: req.ProjectID}, - Domain: primaryHostname, - DeploymentID: sql.NullString{Valid: true, String: req.DeploymentID}, - CreatedAt: time.Now().UnixMilli(), - UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()}, - Type: db.DomainsTypeCustom, - }) - if insertErr != nil { - w.logger.Error("failed to create domain", "error", insertErr, "domain", primaryHostname, "deployment_id", req.DeploymentID) - return nil, fmt.Errorf("failed to create route for hostname %s: %w", primaryHostname, insertErr) + w.logger.Info("generated all domains", + "deployment_id", req.DeploymentID, + "total_domains", len(domains), + "domains", domains) + + return domains, nil + }) + if err != nil { + w.logger.Error("failed to generate domains", "error", err, "deployment_id", req.DeploymentID) + return err + } + + // Create database entries for all domains + err = hydra.StepVoid(ctx, "create-domain-entries", func(stepCtx context.Context) error { + w.logger.Info("creating domain database entries", "deployment_id", req.DeploymentID, "domains", allDomains) + + // Prepare bulk insert parameters + domainParams := make([]db.InsertDomainParams, 0, len(allDomains)) + currentTime := time.Now().UnixMilli() + + for _, domain := range allDomains { + domainID := uid.New("domain") + domainParams = append(domainParams, db.InsertDomainParams{ + ID: domainID, + WorkspaceID: req.WorkspaceID, + ProjectID: sql.NullString{Valid: true, String: req.ProjectID}, + Domain: domain, + DeploymentID: sql.NullString{Valid: true, String: req.DeploymentID}, + CreatedAt: currentTime, + UpdatedAt: sql.NullInt64{Valid: true, Int64: currentTime}, + Type: db.DomainsTypeCustom, + }) + } + + // Perform bulk insert + if err := db.BulkQuery.InsertDomains(stepCtx, w.db.RW(), domainParams); err != nil { + w.logger.Error("failed to create domain entries in bulk", "error", err, "deployment_id", req.DeploymentID, "domain_count", len(allDomains)) + return fmt.Errorf("failed to create domain entries: %w", err) } - hostnames = append(hostnames, primaryHostname) - w.logger.Info("primary domain assigned successfully", "hostname", primaryHostname, "deployment_id", req.DeploymentID, "domain_id", domainID) + w.logger.Info("domain entries created in bulk", "deployment_id", req.DeploymentID, "domain_count", len(allDomains)) - return hostnames, nil + return nil }) if err != nil { - w.logger.Error("failed to assign domains", "error", err, "deployment_id", req.DeploymentID) + w.logger.Error("failed to create domain entries", "error", err, "deployment_id", req.DeploymentID) return err } - // Step 20: Log assigning domains - err = hydra.StepVoid(ctx, "log-assigning-domains", func(stepCtx context.Context) error { - var message string - if len(assignedHostnames) > 0 { - message = fmt.Sprintf("Assigned hostnames: %s", strings.Join(assignedHostnames, ", ")) - } else { - message = "Domain assignment completed" + // Create gateway configs for all domains in bulk (except local ones) + err = hydra.StepVoid(ctx, "create-gateway-configs-bulk", func(stepCtx context.Context) error { + w.logger.Info("creating gateway configurations in bulk", "deployment_id", req.DeploymentID, "total_domains", len(allDomains)) + + var configsCreated int + var skippedDomains []string + + for _, domain := range allDomains { + if isLocalHostname(domain) { + w.logger.Info("skipping gateway config for local domain", "domain", domain) + skippedDomains = append(skippedDomains, domain) + continue + } + + if err := w.createGatewayConfigForHostname(stepCtx, domain, req.DeploymentID, req.KeyspaceID, createdVMs); err != nil { + w.logger.Error("failed to create gateway config for domain", + "domain", domain, + "error", err, + "deployment_id", req.DeploymentID) + // Continue with other domains rather than failing the entire deployment + continue + } + configsCreated++ } + + w.logger.Info("gateway configurations created in bulk", + "deployment_id", req.DeploymentID, + "total_domains", len(allDomains), + "configs_created", configsCreated, + "skipped_domains", skippedDomains) + return db.Query.InsertDeploymentStep(stepCtx, w.db.RW(), db.InsertDeploymentStepParams{ DeploymentID: req.DeploymentID, Status: db.DeploymentStepsStatusAssigningDomains, - Message: message, + Message: fmt.Sprintf("Created %d gateway configs for %d domains (skipped %d local domains)", configsCreated, len(allDomains), len(skippedDomains)), CreatedAt: time.Now().UnixMilli(), }) }) if err != nil { - w.logger.Error("failed to log assigning domains", "error", err, "deployment_id", req.DeploymentID) + w.logger.Error("failed to create gateway configurations in bulk", "error", err, "deployment_id", req.DeploymentID) return err } - // Step 21: Update deployment status to active + // Update deployment status to active _, err = hydra.Step(ctx, "update-deployment-ready", func(stepCtx context.Context) (*DeploymentResult, error) { completionTime := time.Now().UnixMilli() w.logger.Info("updating deployment status to ready", "deployment_id", req.DeploymentID, "completion_time", completionTime) @@ -514,7 +523,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro } */ - // Step 26: Log completed + // Log deployment completed err = hydra.StepVoid(ctx, "log-completed", func(stepCtx context.Context) error { return db.Query.InsertDeploymentStep(stepCtx, w.db.RW(), db.InsertDeploymentStepParams{ DeploymentID: req.DeploymentID, @@ -541,3 +550,74 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return nil } + +// createGatewayConfigForHostname creates a gateway configuration for a specific hostname +func (w *DeployWorkflow) createGatewayConfigForHostname(ctx context.Context, hostname, deploymentID, keyspaceID string, vms []*metaldv1.GetDeploymentResponse_Vm) error { + w.logger.Info("creating gateway configuration", "hostname", hostname, "deployment_id", deploymentID) + + // Validate partition DB connection + if w.partitionDB == nil { + w.logger.Error("CRITICAL: partition database not initialized for gateway config") + return fmt.Errorf("partition database not initialized for gateway config") + } + + // Create VM protobuf objects for gateway config + gatewayConfig := &partitionv1.GatewayConfig{ + Deployment: &partitionv1.Deployment{ + Id: deploymentID, + IsEnabled: true, + }, + Vms: make([]*partitionv1.VM, len(vms)), + } + for i, vm := range vms { + gatewayConfig.Vms[i] = &partitionv1.VM{ + Id: vm.Id, + } + } + + // Only add AuthConfig if we have a KeyspaceID + if keyspaceID != "" { + gatewayConfig.AuthConfig = &partitionv1.AuthConfig{ + KeyAuthId: keyspaceID, + } + } + + // Marshal protobuf to bytes + configBytes, err := protojson.Marshal(gatewayConfig) + if err != nil { + w.logger.Error("failed to marshal gateway config", "error", err) + return fmt.Errorf("failed to marshal gateway config: %w", err) + } + + // Insert gateway config into partition database + params := partitiondb.UpsertGatewayParams{ + Hostname: hostname, + Config: configBytes, + } + + if err := partitiondb.Query.UpsertGateway(ctx, w.partitionDB.RW(), params); err != nil { + w.logger.Error("failed to upsert gateway config", "error", err, "hostname", hostname) + return fmt.Errorf("failed to upsert gateway config: %w", err) + } + w.logger.Info("gateway configuration created successfully", "hostname", hostname) + return nil +} + +// isLocalHostname checks if a hostname is for local development +func isLocalHostname(hostname string) bool { + localDomains := []string{ + "localhost", + "127.0.0.1", + ".local", + ".dev", + ".test", + } + + for _, local := range localDomains { + if strings.Contains(hostname, local) { + return true + } + } + + return false +} diff --git a/go/apps/ctrl/services/deployment/fallbacks/docker.go b/go/apps/ctrl/services/deployment/fallbacks/docker.go new file mode 100644 index 0000000000..f4df738bd6 --- /dev/null +++ b/go/apps/ctrl/services/deployment/fallbacks/docker.go @@ -0,0 +1,283 @@ +package fallbacks + +import ( + "context" + "fmt" + "io" + "strconv" + "strings" + "sync" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" + metaldv1 "github.com/unkeyed/unkey/go/gen/proto/metald/v1" + "github.com/unkeyed/unkey/go/pkg/otel/logging" + "github.com/unkeyed/unkey/go/pkg/uid" +) + +// DockerBackend implements DeploymentBackend using Docker containers +type DockerBackend struct { + logger logging.Logger + dockerClient *client.Client + deployments map[string]*dockerDeployment + mutex sync.RWMutex +} + +type dockerDeployment struct { + DeploymentID string + ContainerIDs []string + VMIDs []string + Image string + CreatedAt time.Time +} + +// NewDockerBackend creates a new Docker backend +func NewDockerBackend(logger logging.Logger) (*DockerBackend, error) { + dockerClient, err := client.NewClientWithOpts( + client.FromEnv, + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %w", err) + } + + // Verify Docker connection + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, pingErr := dockerClient.Ping(ctx); pingErr != nil { + return nil, fmt.Errorf("failed to connect to Docker daemon: %w", pingErr) + } + + return &DockerBackend{ + logger: logger.With("backend", "docker"), + dockerClient: dockerClient, + deployments: make(map[string]*dockerDeployment), + }, nil +} + +// CreateDeployment creates Docker containers for the deployment +func (d *DockerBackend) CreateDeployment(ctx context.Context, deploymentID string, image string, vmCount int32) ([]string, error) { + d.logger.Info("creating Docker deployment", + "deployment_id", deploymentID, + "image", image, + "vm_count", vmCount) + + // Pull image if not present + if err := d.pullImageIfNeeded(ctx, image); err != nil { + return nil, fmt.Errorf("failed to pull image: %w", err) + } + + deployment := &dockerDeployment{ + DeploymentID: deploymentID, + Image: image, + CreatedAt: time.Now(), + ContainerIDs: make([]string, 0, vmCount), + VMIDs: make([]string, 0, vmCount), + } + + // Create containers + for i := int32(0); i < vmCount; i++ { + vmID := uid.New("vm") + containerName := fmt.Sprintf("unkey-%s-%s", deploymentID, vmID) + + containerID, err := d.createContainer(ctx, containerName, image, vmID, deploymentID) + if err != nil { + // Clean up any created containers on failure + d.cleanupDeployment(ctx, deployment) + return nil, fmt.Errorf("failed to create container %d: %w", i, err) + } + + deployment.ContainerIDs = append(deployment.ContainerIDs, containerID) + deployment.VMIDs = append(deployment.VMIDs, vmID) + + // Start the container + if err := d.dockerClient.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil { + d.cleanupDeployment(ctx, deployment) + return nil, fmt.Errorf("failed to start container %s: %w", containerID, err) + } + + d.logger.Info("container started", + "vm_id", vmID, + "container_id", containerID, + "deployment_id", deploymentID) + } + + // Store deployment + d.mutex.Lock() + d.deployments[deploymentID] = deployment + d.mutex.Unlock() + + return deployment.VMIDs, nil +} + +// GetDeploymentStatus returns the status of deployment VMs +func (d *DockerBackend) GetDeploymentStatus(ctx context.Context, deploymentID string) ([]*metaldv1.GetDeploymentResponse_Vm, error) { + d.mutex.RLock() + deployment, exists := d.deployments[deploymentID] + d.mutex.RUnlock() + + if !exists { + return nil, fmt.Errorf("deployment %s not found", deploymentID) + } + + vms := make([]*metaldv1.GetDeploymentResponse_Vm, 0, len(deployment.ContainerIDs)) + + for i, containerID := range deployment.ContainerIDs { + inspect, err := d.dockerClient.ContainerInspect(ctx, containerID) + if err != nil { + d.logger.Error("failed to inspect container", + "container_id", containerID, + "error", err) + continue + } + + // Determine VM state from container state + state := metaldv1.VmState_VM_STATE_UNSPECIFIED + if inspect.State.Running { + state = metaldv1.VmState_VM_STATE_RUNNING + } else if inspect.State.Paused { + state = metaldv1.VmState_VM_STATE_PAUSED + } else if inspect.State.Dead || inspect.State.OOMKilled { + state = metaldv1.VmState_VM_STATE_SHUTDOWN + } else { + state = metaldv1.VmState_VM_STATE_CREATED + } + + // Get host and port from container + host := "localhost" + port := int32(8080) // Default port + + // Try to get the actual mapped port + if inspect.NetworkSettings != nil && inspect.NetworkSettings.Ports != nil { + for containerPort, bindings := range inspect.NetworkSettings.Ports { + if strings.Contains(string(containerPort), "8080") && len(bindings) > 0 { + if p, err := strconv.Atoi(bindings[0].HostPort); err == nil { + port = int32(p) + } + } + } + } + + vms = append(vms, &metaldv1.GetDeploymentResponse_Vm{ + Id: deployment.VMIDs[i], + State: state, + Host: host, + Port: uint32(port), + }) + } + + return vms, nil +} + +// DeleteDeployment removes all containers in a deployment +func (d *DockerBackend) DeleteDeployment(ctx context.Context, deploymentID string) error { + d.mutex.Lock() + deployment, exists := d.deployments[deploymentID] + if exists { + delete(d.deployments, deploymentID) + } + d.mutex.Unlock() + + if !exists { + return fmt.Errorf("deployment %s not found", deploymentID) + } + + return d.cleanupDeployment(ctx, deployment) +} + +// Type returns the backend type +func (d *DockerBackend) Type() string { + return "docker" +} + +// Helper methods + +func (d *DockerBackend) pullImageIfNeeded(ctx context.Context, imageName string) error { + // Check if image exists locally + _, _, err := d.dockerClient.ImageInspectWithRaw(ctx, imageName) + if err == nil { + d.logger.Info("image found locally", "image", imageName) + return nil + } + + d.logger.Info("pulling image", "image", imageName) + reader, err := d.dockerClient.ImagePull(ctx, imageName, image.PullOptions{}) + if err != nil { + return fmt.Errorf("failed to pull image %s: %w", imageName, err) + } + defer reader.Close() + + // Read the output to ensure pull completes + _, err = io.Copy(io.Discard, reader) + if err != nil { + return fmt.Errorf("failed to read pull response: %w", err) + } + + d.logger.Info("image pulled successfully", "image", imageName) + return nil +} + +func (d *DockerBackend) createContainer(ctx context.Context, name string, imageName string, vmID string, deploymentID string) (string, error) { + config := &container.Config{ + Image: imageName, + Labels: map[string]string{ + "unkey.vm.id": vmID, + "unkey.deployment.id": deploymentID, + "unkey.managed.by": "ctrl-fallback", + }, + ExposedPorts: nat.PortSet{ + "8080/tcp": struct{}{}, + }, + } + + hostConfig := &container.HostConfig{ + PortBindings: nat.PortMap{ + "8080/tcp": []nat.PortBinding{ + { + HostIP: "0.0.0.0", + HostPort: "0", // Let Docker assign a random port + }, + }, + }, + AutoRemove: false, + Resources: container.Resources{ + Memory: 1024 * 1024 * 1024, // 1GB + NanoCPUs: 1000000000, // 1 CPU + }, + } + + resp, err := d.dockerClient.ContainerCreate(ctx, config, hostConfig, nil, nil, name) + if err != nil { + return "", fmt.Errorf("failed to create container: %w", err) + } + + return resp.ID, nil +} + +func (d *DockerBackend) cleanupDeployment(ctx context.Context, deployment *dockerDeployment) error { + var lastErr error + for _, containerID := range deployment.ContainerIDs { + // Stop container + if err := d.dockerClient.ContainerStop(ctx, containerID, container.StopOptions{}); err != nil { + d.logger.Error("failed to stop container", + "container_id", containerID, + "error", err) + lastErr = err + } + + // Remove container + if err := d.dockerClient.ContainerRemove(ctx, containerID, container.RemoveOptions{ + Force: true, + }); err != nil { + d.logger.Error("failed to remove container", + "container_id", containerID, + "error", err) + lastErr = err + } + } + return lastErr +} diff --git a/go/apps/ctrl/services/deployment/fallbacks/interface.go b/go/apps/ctrl/services/deployment/fallbacks/interface.go new file mode 100644 index 0000000000..114e5ab520 --- /dev/null +++ b/go/apps/ctrl/services/deployment/fallbacks/interface.go @@ -0,0 +1,36 @@ +package fallbacks + +import ( + "context" + "fmt" + + metaldv1 "github.com/unkeyed/unkey/go/gen/proto/metald/v1" + "github.com/unkeyed/unkey/go/pkg/otel/logging" +) + +// DeploymentBackend defines the interface for deployment backends +type DeploymentBackend interface { + // CreateDeployment creates a new deployment and returns VM IDs + CreateDeployment(ctx context.Context, deploymentID string, image string, vmCount int32) ([]string, error) + + // GetDeploymentStatus checks if deployment VMs are ready + GetDeploymentStatus(ctx context.Context, deploymentID string) ([]*metaldv1.GetDeploymentResponse_Vm, error) + + // DeleteDeployment removes a deployment and its resources + DeleteDeployment(ctx context.Context, deploymentID string) error + + // Type returns the backend type name + Type() string +} + +// NewBackend creates a new deployment backend based on the specified type +func NewBackend(backendType string, logger logging.Logger) (DeploymentBackend, error) { + switch backendType { + case "k8s": + return NewK8sBackend(logger) + case "docker": + return NewDockerBackend(logger) + default: + return nil, fmt.Errorf("unsupported backend type: %s", backendType) + } +} diff --git a/go/apps/ctrl/services/deployment/fallbacks/k8s.go b/go/apps/ctrl/services/deployment/fallbacks/k8s.go new file mode 100644 index 0000000000..eaeab064ab --- /dev/null +++ b/go/apps/ctrl/services/deployment/fallbacks/k8s.go @@ -0,0 +1,292 @@ +package fallbacks + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + metaldv1 "github.com/unkeyed/unkey/go/gen/proto/metald/v1" + "github.com/unkeyed/unkey/go/pkg/otel/logging" + "github.com/unkeyed/unkey/go/pkg/uid" +) + +// K8sBackend implements DeploymentBackend using Kubernetes pods +type K8sBackend struct { + logger logging.Logger + clientset *kubernetes.Clientset + namespace string + deployments map[string]*k8sDeployment + mutex sync.RWMutex +} + +type k8sDeployment struct { + DeploymentID string + DeploymentName string + ServiceName string + VMIDs []string + Image string + CreatedAt time.Time +} + +// NewK8sBackend creates a new Kubernetes backend +func NewK8sBackend(logger logging.Logger) (*K8sBackend, error) { + // Create in-cluster config + config, err := rest.InClusterConfig() + if err != nil { + return nil, fmt.Errorf("failed to create in-cluster config: %w", err) + } + + // Create clientset + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create Kubernetes clientset: %w", err) + } + + // Get namespace from service account or use default + namespace := "default" + if ns, err := readNamespaceFromServiceAccount(); err == nil && ns != "" { + namespace = ns + } + + return &K8sBackend{ + logger: logger.With("backend", "k8s"), + clientset: clientset, + namespace: namespace, + deployments: make(map[string]*k8sDeployment), + }, nil +} + +// CreateDeployment creates Kubernetes pods for the deployment +func (k *K8sBackend) CreateDeployment(ctx context.Context, deploymentID string, image string, vmCount int32) ([]string, error) { + k.logger.Info("creating Kubernetes deployment", + "deployment_id", deploymentID, + "image", image, + "vm_count", vmCount, + "namespace", k.namespace) + + // Generate VM IDs + vmIDs := make([]string, vmCount) + for i := int32(0); i < vmCount; i++ { + vmIDs[i] = uid.New("vm") + } + + deploymentName := fmt.Sprintf("unkey-%s", deploymentID) + serviceName := fmt.Sprintf("unkey-svc-%s", deploymentID) + + // Create deployment + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: deploymentName, + Namespace: k.namespace, + Labels: map[string]string{ + "unkey.deployment.id": deploymentID, + "unkey.managed.by": "ctrl-fallback", + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &vmCount, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "unkey.deployment.id": deploymentID, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "unkey.deployment.id": deploymentID, + "unkey.managed.by": "ctrl-fallback", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: image, + Ports: []corev1.ContainerPort{ + { + ContainerPort: 8080, + Protocol: corev1.ProtocolTCP, + }, + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1000m"), + corev1.ResourceMemory: resource.MustParse("1Gi"), + }, + }, + }, + }, + }, + }, + }, + } + + _, err := k.clientset.AppsV1().Deployments(k.namespace).Create(ctx, deployment, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to create deployment: %w", err) + } + + // Create service to expose the deployment + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: k.namespace, + Labels: map[string]string{ + "unkey.deployment.id": deploymentID, + "unkey.managed.by": "ctrl-fallback", + }, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Selector: map[string]string{ + "unkey.deployment.id": deploymentID, + }, + Ports: []corev1.ServicePort{ + { + Port: 8080, + TargetPort: intstr.FromInt(8080), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } + + _, err = k.clientset.CoreV1().Services(k.namespace).Create(ctx, service, metav1.CreateOptions{}) + if err != nil { + // Clean up deployment if service creation fails + k.clientset.AppsV1().Deployments(k.namespace).Delete(ctx, deploymentName, metav1.DeleteOptions{}) + return nil, fmt.Errorf("failed to create service: %w", err) + } + + // Store deployment info + k.mutex.Lock() + k.deployments[deploymentID] = &k8sDeployment{ + DeploymentID: deploymentID, + DeploymentName: deploymentName, + ServiceName: serviceName, + VMIDs: vmIDs, + Image: image, + CreatedAt: time.Now(), + } + k.mutex.Unlock() + + k.logger.Info("Kubernetes deployment created", + "deployment_id", deploymentID, + "deployment_name", deploymentName, + "service_name", serviceName, + "vm_ids", vmIDs) + + return vmIDs, nil +} + +// GetDeploymentStatus returns the status of deployment VMs +func (k *K8sBackend) GetDeploymentStatus(ctx context.Context, deploymentID string) ([]*metaldv1.GetDeploymentResponse_Vm, error) { + k.mutex.RLock() + deploymentInfo, exists := k.deployments[deploymentID] + k.mutex.RUnlock() + + if !exists { + return nil, fmt.Errorf("deployment %s not found", deploymentID) + } + + // Get deployment status + deployment, err := k.clientset.AppsV1().Deployments(k.namespace).Get(ctx, deploymentInfo.DeploymentName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get deployment: %w", err) + } + + // Get service to find the cluster IP + service, err := k.clientset.CoreV1().Services(k.namespace).Get(ctx, deploymentInfo.ServiceName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get service: %w", err) + } + + // Create VM responses + vms := make([]*metaldv1.GetDeploymentResponse_Vm, 0, len(deploymentInfo.VMIDs)) + + // Determine state based on deployment status + state := metaldv1.VmState_VM_STATE_UNSPECIFIED + if deployment.Status.ReadyReplicas == *deployment.Spec.Replicas { + state = metaldv1.VmState_VM_STATE_RUNNING + } else if deployment.Status.ReadyReplicas > 0 { + state = metaldv1.VmState_VM_STATE_RUNNING // Partially running + } else { + state = metaldv1.VmState_VM_STATE_CREATED + } + + // For each VM ID, create a response + for _, vmID := range deploymentInfo.VMIDs { + vms = append(vms, &metaldv1.GetDeploymentResponse_Vm{ + Id: vmID, + State: state, + Host: service.Spec.ClusterIP, + Port: 8080, + }) + } + + return vms, nil +} + +// DeleteDeployment removes the Kubernetes deployment and service +func (k *K8sBackend) DeleteDeployment(ctx context.Context, deploymentID string) error { + k.mutex.Lock() + deploymentInfo, exists := k.deployments[deploymentID] + if exists { + delete(k.deployments, deploymentID) + } + k.mutex.Unlock() + + if !exists { + return fmt.Errorf("deployment %s not found", deploymentID) + } + + // Delete service + if err := k.clientset.CoreV1().Services(k.namespace).Delete(ctx, deploymentInfo.ServiceName, metav1.DeleteOptions{}); err != nil { + k.logger.Error("failed to delete service", + "service_name", deploymentInfo.ServiceName, + "error", err) + } + + // Delete deployment + if err := k.clientset.AppsV1().Deployments(k.namespace).Delete(ctx, deploymentInfo.DeploymentName, metav1.DeleteOptions{}); err != nil { + k.logger.Error("failed to delete deployment", + "deployment_name", deploymentInfo.DeploymentName, + "error", err) + return err + } + + k.logger.Info("Kubernetes deployment deleted", + "deployment_id", deploymentID, + "deployment_name", deploymentInfo.DeploymentName, + "service_name", deploymentInfo.ServiceName) + + return nil +} + +// Type returns the backend type +func (k *K8sBackend) Type() string { + return "k8s" +} + +// Helper function to read namespace from service account +func readNamespaceFromServiceAccount() (string, error) { + data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace") + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/go/apps/gw/run.go b/go/apps/gw/run.go index df91c512ae..b9daba01bb 100644 --- a/go/apps/gw/run.go +++ b/go/apps/gw/run.go @@ -24,7 +24,6 @@ import ( "github.com/unkeyed/unkey/go/pkg/db" "github.com/unkeyed/unkey/go/pkg/otel" "github.com/unkeyed/unkey/go/pkg/otel/logging" - partitiondb "github.com/unkeyed/unkey/go/pkg/partition/db" "github.com/unkeyed/unkey/go/pkg/prometheus" "github.com/unkeyed/unkey/go/pkg/rbac" "github.com/unkeyed/unkey/go/pkg/shutdown" @@ -131,7 +130,7 @@ func Run(ctx context.Context, cfg Config) error { } } - partitionedDB, err := partitiondb.New(partitiondb.Config{ + partitionedDB, err := db.New(db.Config{ PrimaryDSN: cfg.DatabasePrimary, ReadOnlyDSN: cfg.DatabaseReadonlyReplica, Logger: logger, diff --git a/go/apps/gw/services/certmanager/certmanager.go b/go/apps/gw/services/certmanager/certmanager.go index e4376ff84f..a1ed3bc055 100644 --- a/go/apps/gw/services/certmanager/certmanager.go +++ b/go/apps/gw/services/certmanager/certmanager.go @@ -8,8 +8,9 @@ import ( vaultv1 "github.com/unkeyed/unkey/go/gen/proto/vault/v1" "github.com/unkeyed/unkey/go/internal/services/caches" "github.com/unkeyed/unkey/go/pkg/cache" + "github.com/unkeyed/unkey/go/pkg/db" "github.com/unkeyed/unkey/go/pkg/otel/logging" - "github.com/unkeyed/unkey/go/pkg/partition/db" + pdb "github.com/unkeyed/unkey/go/pkg/partition/db" "github.com/unkeyed/unkey/go/pkg/vault" ) @@ -53,7 +54,7 @@ func (s *service) GetCertificate(ctx context.Context, domain string) (*tls.Certi } cert, hit, err := s.cache.SWR(ctx, domain, func(ctx context.Context) (tls.Certificate, error) { - row, err := db.Query.FindCertificateByHostname(ctx, s.db.RO(), domain) + row, err := pdb.Query.FindCertificateByHostname(ctx, s.db.RO(), domain) if err != nil { return tls.Certificate{}, err } diff --git a/go/apps/gw/services/certmanager/interface.go b/go/apps/gw/services/certmanager/interface.go index 78a2a32f31..c73874c3ef 100644 --- a/go/apps/gw/services/certmanager/interface.go +++ b/go/apps/gw/services/certmanager/interface.go @@ -5,8 +5,8 @@ import ( "crypto/tls" "github.com/unkeyed/unkey/go/pkg/cache" + "github.com/unkeyed/unkey/go/pkg/db" "github.com/unkeyed/unkey/go/pkg/otel/logging" - "github.com/unkeyed/unkey/go/pkg/partition/db" "github.com/unkeyed/unkey/go/pkg/vault" ) diff --git a/go/apps/gw/services/routing/interface.go b/go/apps/gw/services/routing/interface.go index fc9cfc1740..5aaaa7572f 100644 --- a/go/apps/gw/services/routing/interface.go +++ b/go/apps/gw/services/routing/interface.go @@ -7,8 +7,9 @@ import ( partitionv1 "github.com/unkeyed/unkey/go/gen/proto/partition/v1" "github.com/unkeyed/unkey/go/pkg/cache" "github.com/unkeyed/unkey/go/pkg/clock" + "github.com/unkeyed/unkey/go/pkg/db" "github.com/unkeyed/unkey/go/pkg/otel/logging" - "github.com/unkeyed/unkey/go/pkg/partition/db" + pdb "github.com/unkeyed/unkey/go/pkg/partition/db" ) // Service handles gateway configuration lookup and VM selection. @@ -27,5 +28,5 @@ type Config struct { Clock clock.Clock GatewayConfigCache cache.Cache[string, *partitionv1.GatewayConfig] - VMCache cache.Cache[string, db.Vm] + VMCache cache.Cache[string, pdb.Vm] } diff --git a/go/apps/gw/services/routing/service.go b/go/apps/gw/services/routing/service.go index d2c894c95f..b1b3fabaa1 100644 --- a/go/apps/gw/services/routing/service.go +++ b/go/apps/gw/services/routing/service.go @@ -11,10 +11,11 @@ import ( "github.com/unkeyed/unkey/go/pkg/assert" "github.com/unkeyed/unkey/go/pkg/cache" "github.com/unkeyed/unkey/go/pkg/codes" + "github.com/unkeyed/unkey/go/pkg/db" "github.com/unkeyed/unkey/go/pkg/fault" "github.com/unkeyed/unkey/go/pkg/otel/logging" - "github.com/unkeyed/unkey/go/pkg/partition/db" - "google.golang.org/protobuf/proto" + pdb "github.com/unkeyed/unkey/go/pkg/partition/db" + "google.golang.org/protobuf/encoding/protojson" ) // service implements the RoutingService interface with database backend. @@ -23,7 +24,7 @@ type service struct { logger logging.Logger gatewayConfigCache cache.Cache[string, *partitionv1.GatewayConfig] - vmCache cache.Cache[string, db.Vm] + vmCache cache.Cache[string, pdb.Vm] } var _ Service = (*service)(nil) @@ -50,14 +51,14 @@ func New(config Config) (*service, error) { // GetTarget retrieves target configuration by ID. func (s *service) GetConfig(ctx context.Context, host string) (*partitionv1.GatewayConfig, error) { config, hit, err := s.gatewayConfigCache.SWR(ctx, host, func(ctx context.Context) (*partitionv1.GatewayConfig, error) { - gatewayRow, err := db.Query.FindGatewayByHostname(ctx, s.db.RO(), host) + gatewayRow, err := pdb.Query.FindGatewayByHostname(ctx, s.db.RO(), host) if err != nil { return nil, err } // Unmarshal the protobuf blob from the database var gatewayConfig partitionv1.GatewayConfig - if err := proto.Unmarshal(gatewayRow.Config, &gatewayConfig); err != nil { + if err := protojson.Unmarshal(gatewayRow.Config, &gatewayConfig); err != nil { return nil, fmt.Errorf("failed to unmarshal gateway config: %w", err) } @@ -100,11 +101,11 @@ func (s *service) SelectVM(ctx context.Context, config *partitionv1.GatewayConfi return nil, fmt.Errorf("no VMs available for gateway %s", config.Deployment.Id) } - availableVms := make([]db.Vm, 0) + availableVms := make([]pdb.Vm, 0) for _, vm := range config.Vms { - vm, hit, err := s.vmCache.SWR(ctx, vm.Id, func(ctx context.Context) (db.Vm, error) { + vm, hit, err := s.vmCache.SWR(ctx, vm.Id, func(ctx context.Context) (pdb.Vm, error) { // refactor: this is bad BAD, we should really add a getMany method to the cache - return db.Query.FindVMById(ctx, s.db.RO(), vm.Id) + return pdb.Query.FindVMById(ctx, s.db.RO(), vm.Id) }, caches.DefaultFindFirstOp) if err != nil { @@ -119,7 +120,7 @@ func (s *service) SelectVM(ctx context.Context, config *partitionv1.GatewayConfi continue } - if vm.Status != db.VmsStatusRunning { + if vm.Status != pdb.VmsStatusRunning { continue } diff --git a/go/cmd/ctrl/main.go b/go/cmd/ctrl/main.go index 1298e48cea..85f2ef8491 100644 --- a/go/cmd/ctrl/main.go +++ b/go/cmd/ctrl/main.go @@ -72,6 +72,7 @@ var Cmd = &cli.Command{ cli.Required(), cli.EnvVar("UNKEY_VAULT_S3_ACCESS_KEY_SECRET")), cli.Bool("acme-enabled", "Enable Let's Encrypt for acme challenges", cli.EnvVar("UNKEY_ACME_ENABLED")), + cli.String("metalD-fallback", "Whether to call metalD or go to the fallback (docker or k8s)", cli.EnvVar("UNKEY_METAL_FALLBACK")), }, Action: action, } @@ -128,7 +129,8 @@ func action(ctx context.Context, cmd *cli.Command) error { AccessKeyID: cmd.String("vault-s3-access-key-id"), }, - AcmeEnabled: cmd.Bool("acme-enabled"), + AcmeEnabled: cmd.Bool("acme-enabled"), + MetalDFallback: cmd.String("metalD-fallback"), // Common Clock: clock.New(), diff --git a/go/cmd/deploy/build_docker.go b/go/cmd/deploy/build_docker.go index 0991e52d15..7aa20e4b8e 100644 --- a/go/cmd/deploy/build_docker.go +++ b/go/cmd/deploy/build_docker.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "sync" "time" @@ -38,12 +39,58 @@ var ( ErrBuildTimeout = errors.New("docker build timed out") ) +// sanitizeDockerTag sanitizes a string to be valid for Docker tags +// Official Docker tag grammar: /[\w][\w.-]{0,127}/ +// - First char: word character (a-zA-Z0-9_) +// - Remaining chars: word characters, periods, or dashes +// - Maximum 128 characters total +func sanitizeDockerTag(input string) string { + if input == "" { + return "main" + } + + // Convert to lowercase (Docker registries are case-insensitive) + result := strings.ToLower(input) + + // Replace invalid characters with dashes + // Keep only: a-z, A-Z, 0-9, _, ., - + invalidChars := regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + result = invalidChars.ReplaceAllString(result, "-") + + // Ensure first character is a word character (a-zA-Z0-9_) + // If it starts with . or -, prepend with a valid character + if len(result) > 0 && !regexp.MustCompile(`^[a-zA-Z0-9_]`).MatchString(result) { + result = "v" + result + } + + // Remove consecutive dashes for cleaner tags + multiDash := regexp.MustCompile(`-{2,}`) + result = multiDash.ReplaceAllString(result, "-") + + // Limit to 64 characters (leaving room for SHA suffix in the full image tag) + if len(result) > 64 { + result = result[:64] + // Ensure it doesn't end with dash after truncation + result = strings.TrimRight(result, "-") + } + + // Final safety check - ensure we have a valid tag + if result == "" || !regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9._-]*$`).MatchString(result) { + return "main" + } + + return result +} + // generateImageTag creates a unique tag for the Docker image func generateImageTag(opts DeployOptions, gitInfo git.Info) string { + // Sanitize branch name for Docker tag compatibility + cleanBranch := sanitizeDockerTag(opts.Branch) + if gitInfo.ShortSHA != "" { - return fmt.Sprintf("%s-%s", opts.Branch, gitInfo.ShortSHA) + return fmt.Sprintf("%s-%s", cleanBranch, gitInfo.ShortSHA) } - return fmt.Sprintf("%s-%d", opts.Branch, time.Now().Unix()) + return fmt.Sprintf("%s-%d", cleanBranch, time.Now().Unix()) } // isDockerAvailable checks if Docker is installed and accessible diff --git a/go/cmd/gw/main.go b/go/cmd/gw/main.go index 8473523305..d21f697765 100644 --- a/go/cmd/gw/main.go +++ b/go/cmd/gw/main.go @@ -18,7 +18,7 @@ var Cmd = &cli.Command{ cli.Default(6060), cli.EnvVar("UNKEY_HTTP_PORT")), cli.Int("https-port", "HTTP port for the API server to listen on. Default: 6060", - cli.Default(6060), cli.EnvVar("UNKEY_HTTP_PORT")), + cli.Default(6060), cli.EnvVar("UNKEY_HTTPS_PORT")), cli.Bool("tls-enabled", "Enable TLS termination for the gateway. Default: false", cli.Default(false), cli.EnvVar("UNKEY_TLS_ENABLED")), diff --git a/go/go.mod b/go/go.mod index da09799e3f..c081431e0c 100644 --- a/go/go.mod +++ b/go/go.mod @@ -74,14 +74,23 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/cubicdaiya/gonp v1.0.4 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.4.0+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dolthub/maphash v0.1.0 // indirect github.com/dprotaso/go-yit v0.0.0-20250513224043-18a80f8f6df4 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.8.4 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/fatih/structtag v1.2.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gammazero/deque v1.0.0 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect @@ -90,8 +99,11 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/google/cel-go v0.26.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -101,16 +113,22 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/miekg/dns v1.1.67 // indirect + github.com/moby/docker-image-spec v1.3.1 // 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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/paulmach/orb v0.11.1 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pganalyze/pg_query_go/v5 v5.1.0 // indirect @@ -119,6 +137,7 @@ require ( github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect github.com/pingcap/log v1.1.0 // indirect github.com/pingcap/tidb/pkg/parser v0.0.0-20241203170126-9812d85d0d25 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -149,32 +168,51 @@ require ( github.com/wasilibs/go-pgquery v0.0.0-20240606042535-c0843d6592cc // indirect github.com/wasilibs/wazero-helpers v0.0.0-20240604052452-61d7981e9a38 // indirect github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yargevad/filepathx v1.0.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect go.opentelemetry.io/otel/log v0.12.2 // indirect go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect golang.org/x/mod v0.27.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect + golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.36.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/grpc v1.74.2 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.34.1 // indirect + k8s.io/apimachinery v0.34.1 // indirect + k8s.io/client-go v0.34.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect modernc.org/libc v1.61.13 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.9.1 // indirect modernc.org/sqlite v1.36.2 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) replace github.com/unkeyed/unkey/go/deploy/pkg/tls => ./deploy/pkg/tls diff --git a/go/go.sum b/go/go.sum index dc87c8914a..cc75d4fa92 100644 --- a/go/go.sum +++ b/go/go.sum @@ -83,7 +83,12 @@ github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7 github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= 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/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -93,8 +98,16 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v28.4.0+incompatible h1:KVC7bz5zJY/4AZe/78BIvCnPsLaC9T/zh72xnlrTTOk= +github.com/docker/docker v28.4.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= @@ -104,11 +117,17 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +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/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34= github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo= github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= @@ -129,8 +148,12 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= @@ -139,6 +162,7 @@ github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRj github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= @@ -147,10 +171,13 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -174,6 +201,8 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= @@ -181,6 +210,7 @@ github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -193,6 +223,7 @@ github.com/lmittmann/tint v1.1.1 h1:xmmGuinUsCSxWdwH1OqMUQ4tzQsq3BdjJLAAmVKJ9Dw= github.com/lmittmann/tint v1.1.1/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -201,6 +232,14 @@ github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc= github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4= github.com/miekg/dns v1.1.67 h1:kg0EHj0G4bfT5/oOys6HhZw4vmMlnoZ+gDu8tJ/AlI0= github.com/miekg/dns v1.1.67/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= @@ -229,6 +268,10 @@ github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1Cpa github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= @@ -252,6 +295,7 @@ github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoO github.com/pingcap/tidb/pkg/parser v0.0.0-20241203170126-9812d85d0d25 h1:sAHMshrilTiR9ue2SktI/tVVT2gB4kNaQaY5pbs0YQQ= github.com/pingcap/tidb/pkg/parser v0.0.0-20241203170126-9812d85d0d25/go.mod h1:Hju1TEWZvrctQKbztTRwXH7rd41Yq0Pgmq4PrEKcq7o= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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= @@ -344,6 +388,8 @@ github.com/wasilibs/wazero-helpers v0.0.0-20240604052452-61d7981e9a38 h1:RBu75fh github.com/wasilibs/wazero-helpers v0.0.0-20240604052452-61d7981e9a38/go.mod h1:Z80JvMwvze8KUlVQIdw9L7OSskZJ1yxlpi4AQhoQe4s= github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd h1:dLuIF2kX9c+KknGJUdJi1Il1SDiTSK158/BB9kdgAew= github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd/go.mod h1:DbzwytT4g/odXquuOCqroKvtxxldI4nb3nuesHF/Exo= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= @@ -365,6 +411,8 @@ go.opentelemetry.io/contrib/bridges/otelslog v0.11.0 h1:EMIiYTms4Z4m3bBuKp1VmMNR go.opentelemetry.io/contrib/bridges/otelslog v0.11.0/go.mod h1:DIEZmUR7tzuOOVUTDKvkGWtYWSHFV18Qg8+GMb8wPJw= go.opentelemetry.io/contrib/bridges/prometheus v0.61.0 h1:RyrtJzu5MAmIcbRrwg75b+w3RlZCP0vJByDVzcpAe3M= go.opentelemetry.io/contrib/bridges/prometheus v0.61.0/go.mod h1:tirr4p9NXbzjlbruiRGp53IzlYrDk5CO2fdHj0sSSaY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/processors/minsev v0.9.0 h1:eKlDcNp+GSygGk6PMJJyEdej+E1HteUy+KsY2YzaLbM= go.opentelemetry.io/contrib/processors/minsev v0.9.0/go.mod h1:p8UCIy0r8hjrVD1Hb/4IUDSIpiZmlJl5DhCZOYgMWc4= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= @@ -408,6 +456,10 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -432,6 +484,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -453,12 +507,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -488,7 +546,11 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= @@ -505,6 +567,18 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +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-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= @@ -529,3 +603,11 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/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.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/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= From b2d0c6fa3a1f4df17222130a6f0b2283eb049540 Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 10 Sep 2025 16:44:11 +0200 Subject: [PATCH 03/20] chore(ctrl): cleanup logs and use running vm status --- go/apps/ctrl/services/deployment/backend.go | 1 + .../services/deployment/deploy_workflow.go | 111 ++++++++++-------- go/apps/gw/services/caches/caches.go | 12 +- .../db/bulk_gateway_upsert.sql_generated.go | 5 +- .../db/gateway_upsert.sql_generated.go | 15 +-- go/pkg/partition/db/querier_generated.go | 4 +- .../partition/db/queries/gateway_upsert.sql | 4 +- 7 files changed, 86 insertions(+), 66 deletions(-) diff --git a/go/apps/ctrl/services/deployment/backend.go b/go/apps/ctrl/services/deployment/backend.go index f1d2f0bd77..2a0b3cbe33 100644 --- a/go/apps/ctrl/services/deployment/backend.go +++ b/go/apps/ctrl/services/deployment/backend.go @@ -59,6 +59,7 @@ func NewFallbackBackend(backendType string, logger logging.Logger) (*FallbackBac if err != nil { return nil, err } + return &FallbackBackend{ backend: backend, logger: logger, diff --git a/go/apps/ctrl/services/deployment/deploy_workflow.go b/go/apps/ctrl/services/deployment/deploy_workflow.go index bc6a94647c..e833e9d25c 100644 --- a/go/apps/ctrl/services/deployment/deploy_workflow.go +++ b/go/apps/ctrl/services/deployment/deploy_workflow.go @@ -103,7 +103,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro // Update version status to building _, err = hydra.Step(ctx, "update-version-building", func(stepCtx context.Context) (*struct{}, error) { - w.logger.Info("updating deployment status to building", "deployment_id", req.DeploymentID) updateErr := db.Query.UpdateDeploymentStatus(stepCtx, w.db.RW(), db.UpdateDeploymentStatusParams{ ID: req.DeploymentID, Status: db.DeploymentsStatusBuilding, @@ -112,7 +111,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro if updateErr != nil { return nil, fmt.Errorf("failed to update version status to building: %w", updateErr) } - w.logger.Info("deployment status updated to building", "deployment_id", req.DeploymentID) return &struct{}{}, nil }) if err != nil { @@ -121,7 +119,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro } deployment, err := hydra.Step(ctx, "metald-create-deployment", func(stepCtx context.Context) (*metaldv1.CreateDeploymentResponse, error) { - w.logger.Info("creating deployment", "deployment_id", req.DeploymentID, "docker_image", req.DockerImage, "workspace_id", req.WorkspaceID, "project_id", req.ProjectID) if w.deploymentBackend == nil { return nil, fmt.Errorf("deployment backend not initialized") @@ -151,12 +148,10 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return err } - w.logger.Info("Deployment created", "vm_ids", deployment.GetVmIds()) + w.logger.Info("deployment created", "deployment_id", req.DeploymentID, "vm_count", len(deployment.GetVmIds())) // Update version status to deploying _, err = hydra.Step(ctx, "update-version-deploying", func(stepCtx context.Context) (*struct{}, error) { - w.logger.Info("starting deployment", "deployment_id", req.DeploymentID) - deployingErr := db.Query.UpdateDeploymentStatus(stepCtx, w.db.RW(), db.UpdateDeploymentStatusParams{ ID: req.DeploymentID, Status: db.DeploymentsStatusDeploying, @@ -177,7 +172,9 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro for i := range 300 { time.Sleep(time.Second) - w.logger.Info("Polling deployment", "i", i) + if i%10 == 0 { // Log every 10 seconds instead of every second + w.logger.Info("polling deployment status", "deployment_id", req.DeploymentID, "iteration", i) + } if w.deploymentBackend == nil { return nil, fmt.Errorf("deployment backend not initialized") @@ -197,9 +194,9 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro ID: instance.Id, DeploymentID: req.DeploymentID, Address: sql.NullString{Valid: true, String: fmt.Sprintf("%s:%d", instance.Host, instance.Port)}, - CpuMillicores: 1000, // TODO derive from spec - MemoryMb: 1024, // TODO derive from spec - Status: partitiondb.VmsStatusAllocated, // TODO + CpuMillicores: 1000, // TODO derive from spec + MemoryMb: 1024, // TODO derive from spec + Status: partitiondb.VmsStatusRunning, // TODO }); err != nil { w.logger.Error("failed to upsert VM", "error", err, "vm_id", instance.Id) return nil, fmt.Errorf("failed to upsert VM %s: %w", instance.Id, err) @@ -208,7 +205,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro instances[instance.Id] = instance if instance.State != metaldv1.VmState_VM_STATE_RUNNING { allReady = false - w.logger.Debug("VM not ready", "vm_id", instance.Id, "state", instance.State) + w.logger.Debug("vm state changed", "vm_id", instance.Id, "state", instance.State) } } } @@ -227,14 +224,12 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro // Generate all domains (custom + auto-generated) allDomains, err := hydra.Step(ctx, "generate-all-domains", func(stepCtx context.Context) ([]string, error) { - w.logger.Info("generating all domains for deployment", "deployment_id", req.DeploymentID) var domains []string // Add custom hostname if provided if req.Hostname != "" { domains = append(domains, req.Hostname) - w.logger.Info("added custom hostname", "hostname", req.Hostname) } // Generate auto-generated hostname for this deployment @@ -271,7 +266,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro // Create database entries for all domains err = hydra.StepVoid(ctx, "create-domain-entries", func(stepCtx context.Context) error { - w.logger.Info("creating domain database entries", "deployment_id", req.DeploymentID, "domains", allDomains) // Prepare bulk insert parameters domainParams := make([]db.InsertDomainParams, 0, len(allDomains)) @@ -308,19 +302,20 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro // Create gateway configs for all domains in bulk (except local ones) err = hydra.StepVoid(ctx, "create-gateway-configs-bulk", func(stepCtx context.Context) error { - w.logger.Info("creating gateway configurations in bulk", "deployment_id", req.DeploymentID, "total_domains", len(allDomains)) - var configsCreated int + // Prepare gateway configs for all non-local domains + var gatewayParams []partitiondb.UpsertGatewayParams var skippedDomains []string for _, domain := range allDomains { if isLocalHostname(domain) { - w.logger.Info("skipping gateway config for local domain", "domain", domain) skippedDomains = append(skippedDomains, domain) continue } - if err := w.createGatewayConfigForHostname(stepCtx, domain, req.DeploymentID, req.KeyspaceID, createdVMs); err != nil { + // Create gateway config for this domain + gatewayConfig, err := w.createGatewayConfig(req.DeploymentID, req.KeyspaceID, createdVMs) + if err != nil { w.logger.Error("failed to create gateway config for domain", "domain", domain, "error", err, @@ -328,19 +323,39 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro // Continue with other domains rather than failing the entire deployment continue } - configsCreated++ + + // Marshal protobuf to bytes + configBytes, err := protojson.Marshal(gatewayConfig) + if err != nil { + w.logger.Error("failed to marshal gateway config", "error", err, "domain", domain) + continue + } + + gatewayParams = append(gatewayParams, partitiondb.UpsertGatewayParams{ + WorkspaceID: req.WorkspaceID, + Hostname: domain, + Config: configBytes, + }) + } + + // Perform bulk upsert for all gateway configs + if len(gatewayParams) > 0 { + if err := partitiondb.BulkQuery.UpsertGateway(stepCtx, w.partitionDB.RW(), gatewayParams); err != nil { + w.logger.Error("failed to upsert gateway configs in bulk", "error", err, "deployment_id", req.DeploymentID, "config_count", len(gatewayParams)) + return fmt.Errorf("failed to upsert gateway configs: %w", err) + } } w.logger.Info("gateway configurations created in bulk", "deployment_id", req.DeploymentID, "total_domains", len(allDomains), - "configs_created", configsCreated, + "configs_created", len(gatewayParams), "skipped_domains", skippedDomains) return db.Query.InsertDeploymentStep(stepCtx, w.db.RW(), db.InsertDeploymentStepParams{ DeploymentID: req.DeploymentID, Status: db.DeploymentStepsStatusAssigningDomains, - Message: fmt.Sprintf("Created %d gateway configs for %d domains (skipped %d local domains)", configsCreated, len(allDomains), len(skippedDomains)), + Message: fmt.Sprintf("Created %d gateway configs for %d domains (skipped %d local domains)", len(gatewayParams), len(allDomains), len(skippedDomains)), CreatedAt: time.Now().UnixMilli(), }) }) @@ -352,22 +367,19 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro // Update deployment status to active _, err = hydra.Step(ctx, "update-deployment-ready", func(stepCtx context.Context) (*DeploymentResult, error) { completionTime := time.Now().UnixMilli() - w.logger.Info("updating deployment status to ready", "deployment_id", req.DeploymentID, "completion_time", completionTime) activeErr := db.Query.UpdateDeploymentStatus(stepCtx, w.db.RW(), db.UpdateDeploymentStatusParams{ ID: req.DeploymentID, Status: db.DeploymentsStatusReady, UpdatedAt: sql.NullInt64{Valid: true, Int64: completionTime}, }) if activeErr != nil { - w.logger.Error("failed to update deployment status to active", "error", activeErr, "deployment_id", req.DeploymentID) - return nil, fmt.Errorf("failed to update deployment status to active: %w", activeErr) + w.logger.Error("failed to update deployment status to ready", "error", activeErr, "deployment_id", req.DeploymentID) + return nil, fmt.Errorf("failed to update deployment status to ready: %w", activeErr) } - w.logger.Info("deployment complete", "deployment_id", req.DeploymentID, "status", "active") - return &DeploymentResult{ DeploymentID: req.DeploymentID, - Status: "active", + Status: "ready", }, nil }) if err != nil { @@ -537,30 +549,16 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return err } - w.logger.Info("deployment workflow stage completed successfully", "deployment_id", req.DeploymentID) - w.logger.Info("deployment workflow completed", - "execution_id", ctx.ExecutionID(), "deployment_id", req.DeploymentID, "status", "succeeded", - "workspace_id", req.WorkspaceID, - "project_id", req.ProjectID, - "docker_image", req.DockerImage, - "hostname", req.Hostname) + "domains", len(allDomains)) return nil } -// createGatewayConfigForHostname creates a gateway configuration for a specific hostname -func (w *DeployWorkflow) createGatewayConfigForHostname(ctx context.Context, hostname, deploymentID, keyspaceID string, vms []*metaldv1.GetDeploymentResponse_Vm) error { - w.logger.Info("creating gateway configuration", "hostname", hostname, "deployment_id", deploymentID) - - // Validate partition DB connection - if w.partitionDB == nil { - w.logger.Error("CRITICAL: partition database not initialized for gateway config") - return fmt.Errorf("partition database not initialized for gateway config") - } - +// createGatewayConfig creates a gateway configuration protobuf object +func (w *DeployWorkflow) createGatewayConfig(deploymentID, keyspaceID string, vms []*metaldv1.GetDeploymentResponse_Vm) (*partitionv1.GatewayConfig, error) { // Create VM protobuf objects for gateway config gatewayConfig := &partitionv1.GatewayConfig{ Deployment: &partitionv1.Deployment{ @@ -569,6 +567,7 @@ func (w *DeployWorkflow) createGatewayConfigForHostname(ctx context.Context, hos }, Vms: make([]*partitionv1.VM, len(vms)), } + for i, vm := range vms { gatewayConfig.Vms[i] = &partitionv1.VM{ Id: vm.Id, @@ -582,6 +581,24 @@ func (w *DeployWorkflow) createGatewayConfigForHostname(ctx context.Context, hos } } + return gatewayConfig, nil +} + +// createGatewayConfigForHostname creates a gateway configuration for a specific hostname +func (w *DeployWorkflow) createGatewayConfigForHostname(ctx context.Context, workspaceID, hostname, deploymentID, keyspaceID string, vms []*metaldv1.GetDeploymentResponse_Vm) error { + + // Validate partition DB connection + if w.partitionDB == nil { + w.logger.Error("CRITICAL: partition database not initialized for gateway config") + return fmt.Errorf("partition database not initialized for gateway config") + } + + // Create gateway config + gatewayConfig, err := w.createGatewayConfig(deploymentID, keyspaceID, vms) + if err != nil { + return fmt.Errorf("failed to create gateway config: %w", err) + } + // Marshal protobuf to bytes configBytes, err := protojson.Marshal(gatewayConfig) if err != nil { @@ -591,15 +608,15 @@ func (w *DeployWorkflow) createGatewayConfigForHostname(ctx context.Context, hos // Insert gateway config into partition database params := partitiondb.UpsertGatewayParams{ - Hostname: hostname, - Config: configBytes, + WorkspaceID: workspaceID, + Hostname: hostname, + Config: configBytes, } if err := partitiondb.Query.UpsertGateway(ctx, w.partitionDB.RW(), params); err != nil { w.logger.Error("failed to upsert gateway config", "error", err, "hostname", hostname) return fmt.Errorf("failed to upsert gateway config: %w", err) } - w.logger.Info("gateway configuration created successfully", "hostname", hostname) return nil } diff --git a/go/apps/gw/services/caches/caches.go b/go/apps/gw/services/caches/caches.go index 05949e1ab7..baa1779d69 100644 --- a/go/apps/gw/services/caches/caches.go +++ b/go/apps/gw/services/caches/caches.go @@ -73,8 +73,8 @@ type Config struct { // key, err := caches.KeyByHash.Get(ctx, "some-hash") func New(config Config) (Caches, error) { gatewayConfig, err := cache.New(cache.Config[string, *partitionv1.GatewayConfig]{ - Fresh: 5 * time.Minute, - Stale: 30 * time.Minute, + Fresh: time.Minute, + Stale: time.Second * 30, Logger: config.Logger, MaxSize: 10_000, Resource: "gateway_config", @@ -85,8 +85,8 @@ func New(config Config) (Caches, error) { } vmCache, err := cache.New(cache.Config[string, partitiondb.Vm]{ - Fresh: 30 * time.Second, - Stale: 30 * time.Minute, + Fresh: time.Second * 10, + Stale: time.Minute, Logger: config.Logger, MaxSize: 10_000, Resource: "vm", @@ -109,8 +109,8 @@ func New(config Config) (Caches, error) { } verificationKeyByHash, err := cache.New(cache.Config[string, db.FindKeyForVerificationRow]{ - Fresh: 10 * time.Second, - Stale: 10 * time.Minute, + Fresh: time.Minute, + Stale: time.Minute * 10, Logger: config.Logger, MaxSize: 1_000_000, Resource: "verification_key_by_hash", diff --git a/go/pkg/partition/db/bulk_gateway_upsert.sql_generated.go b/go/pkg/partition/db/bulk_gateway_upsert.sql_generated.go index 7f553cfd0c..4adfb43adf 100644 --- a/go/pkg/partition/db/bulk_gateway_upsert.sql_generated.go +++ b/go/pkg/partition/db/bulk_gateway_upsert.sql_generated.go @@ -9,7 +9,7 @@ import ( ) // bulkUpsertGateway is the base query for bulk insert -const bulkUpsertGateway = `INSERT INTO gateways (hostname, config) VALUES %s ON DUPLICATE KEY UPDATE config = VALUES(config)` +const bulkUpsertGateway = `INSERT INTO gateways (workspace_id, hostname, config) VALUES %s ON DUPLICATE KEY UPDATE config = VALUES(config)` // UpsertGateway performs bulk insert in a single query func (q *BulkQueries) UpsertGateway(ctx context.Context, db DBTX, args []UpsertGatewayParams) error { @@ -21,7 +21,7 @@ func (q *BulkQueries) UpsertGateway(ctx context.Context, db DBTX, args []UpsertG // Build the bulk insert query valueClauses := make([]string, len(args)) for i := range args { - valueClauses[i] = "(?, ?)" + valueClauses[i] = "(?, ?, ?)" } bulkQuery := fmt.Sprintf(bulkUpsertGateway, strings.Join(valueClauses, ", ")) @@ -29,6 +29,7 @@ func (q *BulkQueries) UpsertGateway(ctx context.Context, db DBTX, args []UpsertG // Collect all arguments var allArgs []any for _, arg := range args { + allArgs = append(allArgs, arg.WorkspaceID) allArgs = append(allArgs, arg.Hostname) allArgs = append(allArgs, arg.Config) } diff --git a/go/pkg/partition/db/gateway_upsert.sql_generated.go b/go/pkg/partition/db/gateway_upsert.sql_generated.go index 346ba49d34..a7dbaff3ec 100644 --- a/go/pkg/partition/db/gateway_upsert.sql_generated.go +++ b/go/pkg/partition/db/gateway_upsert.sql_generated.go @@ -10,20 +10,21 @@ import ( ) const upsertGateway = `-- name: UpsertGateway :exec -INSERT INTO gateways (hostname, config) -VALUES (?, ?) ON DUPLICATE KEY UPDATE config = VALUES(config) +INSERT INTO gateways (workspace_id, hostname, config) +VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE config = VALUES(config) ` type UpsertGatewayParams struct { - Hostname string `db:"hostname"` - Config []byte `db:"config"` + WorkspaceID string `db:"workspace_id"` + Hostname string `db:"hostname"` + Config []byte `db:"config"` } // UpsertGateway // -// INSERT INTO gateways (hostname, config) -// VALUES (?, ?) ON DUPLICATE KEY UPDATE config = VALUES(config) +// INSERT INTO gateways (workspace_id, hostname, config) +// VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE config = VALUES(config) func (q *Queries) UpsertGateway(ctx context.Context, db DBTX, arg UpsertGatewayParams) error { - _, err := db.ExecContext(ctx, upsertGateway, arg.Hostname, arg.Config) + _, err := db.ExecContext(ctx, upsertGateway, arg.WorkspaceID, arg.Hostname, arg.Config) return err } diff --git a/go/pkg/partition/db/querier_generated.go b/go/pkg/partition/db/querier_generated.go index 2543394def..c0a9cd5762 100644 --- a/go/pkg/partition/db/querier_generated.go +++ b/go/pkg/partition/db/querier_generated.go @@ -39,8 +39,8 @@ type Querier interface { InsertCertificate(ctx context.Context, db DBTX, arg InsertCertificateParams) error //UpsertGateway // - // INSERT INTO gateways (hostname, config) - // VALUES (?, ?) ON DUPLICATE KEY UPDATE config = VALUES(config) + // INSERT INTO gateways (workspace_id, hostname, config) + // VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE config = VALUES(config) UpsertGateway(ctx context.Context, db DBTX, arg UpsertGatewayParams) error //UpsertVM // diff --git a/go/pkg/partition/db/queries/gateway_upsert.sql b/go/pkg/partition/db/queries/gateway_upsert.sql index a159226ea5..329e620016 100644 --- a/go/pkg/partition/db/queries/gateway_upsert.sql +++ b/go/pkg/partition/db/queries/gateway_upsert.sql @@ -1,3 +1,3 @@ -- name: UpsertGateway :exec -INSERT INTO gateways (hostname, config) -VALUES (?, ?) ON DUPLICATE KEY UPDATE config = VALUES(config); +INSERT INTO gateways (workspace_id, hostname, config) +VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE config = VALUES(config); From 986d04710d27e78d420b5369b8c8bfe7e5013d89 Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 10 Sep 2025 21:25:57 +0200 Subject: [PATCH 04/20] chore(ctrl): k8s naming --- go/apps/ctrl/services/deployment/fallbacks/k8s.go | 9 +++++++-- go/cmd/ctrl/main.go | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/go/apps/ctrl/services/deployment/fallbacks/k8s.go b/go/apps/ctrl/services/deployment/fallbacks/k8s.go index eaeab064ab..d60f4d63e7 100644 --- a/go/apps/ctrl/services/deployment/fallbacks/k8s.go +++ b/go/apps/ctrl/services/deployment/fallbacks/k8s.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "sync" "time" @@ -80,8 +81,12 @@ func (k *K8sBackend) CreateDeployment(ctx context.Context, deploymentID string, vmIDs[i] = uid.New("vm") } - deploymentName := fmt.Sprintf("unkey-%s", deploymentID) - serviceName := fmt.Sprintf("unkey-svc-%s", deploymentID) + // Sanitize deployment ID for Kubernetes RFC 1123 compliance + // Must be lowercase, alphanumeric, and hyphens only + sanitizedDeploymentID := strings.ReplaceAll(deploymentID, "_", "-") + sanitizedDeploymentID = strings.ToLower(sanitizedDeploymentID) + deploymentName := fmt.Sprintf("unkey-%s", sanitizedDeploymentID) + serviceName := fmt.Sprintf("unkey-svc-%s", sanitizedDeploymentID) // Create deployment deployment := &appsv1.Deployment{ diff --git a/go/cmd/ctrl/main.go b/go/cmd/ctrl/main.go index 85f2ef8491..a705d75c98 100644 --- a/go/cmd/ctrl/main.go +++ b/go/cmd/ctrl/main.go @@ -72,7 +72,7 @@ var Cmd = &cli.Command{ cli.Required(), cli.EnvVar("UNKEY_VAULT_S3_ACCESS_KEY_SECRET")), cli.Bool("acme-enabled", "Enable Let's Encrypt for acme challenges", cli.EnvVar("UNKEY_ACME_ENABLED")), - cli.String("metalD-fallback", "Whether to call metalD or go to the fallback (docker or k8s)", cli.EnvVar("UNKEY_METAL_FALLBACK")), + cli.String("metalD-fallback", "Whether to call metalD or go to the fallback (docker or k8s)", cli.EnvVar("UNKEY_METALD_FALLBACK")), }, Action: action, } From a83861fecbfdd86713e4f1e0abe7a35bb91410bf Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 10 Sep 2025 21:49:30 +0200 Subject: [PATCH 05/20] use random port --- go/apps/ctrl/services/deployment/fallbacks/k8s.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/go/apps/ctrl/services/deployment/fallbacks/k8s.go b/go/apps/ctrl/services/deployment/fallbacks/k8s.go index d60f4d63e7..5f0df52018 100644 --- a/go/apps/ctrl/services/deployment/fallbacks/k8s.go +++ b/go/apps/ctrl/services/deployment/fallbacks/k8s.go @@ -75,7 +75,6 @@ func (k *K8sBackend) CreateDeployment(ctx context.Context, deploymentID string, "vm_count", vmCount, "namespace", k.namespace) - // Generate VM IDs vmIDs := make([]string, vmCount) for i := int32(0); i < vmCount; i++ { vmIDs[i] = uid.New("vm") @@ -123,6 +122,7 @@ func (k *K8sBackend) CreateDeployment(ctx context.Context, deploymentID string, Protocol: corev1.ProtocolTCP, }, }, + // This REALLY doesn't matter for dev Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("100m"), @@ -156,7 +156,7 @@ func (k *K8sBackend) CreateDeployment(ctx context.Context, deploymentID string, }, }, Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeClusterIP, + Type: corev1.ServiceTypeNodePort, Selector: map[string]string{ "unkey.deployment.id": deploymentID, }, @@ -233,13 +233,19 @@ func (k *K8sBackend) GetDeploymentStatus(ctx context.Context, deploymentID strin state = metaldv1.VmState_VM_STATE_CREATED } + // Get the NodePort from the service + nodePort := int32(8080) // fallback + if len(service.Spec.Ports) > 0 && service.Spec.Ports[0].NodePort != 0 { + nodePort = service.Spec.Ports[0].NodePort + } + // For each VM ID, create a response for _, vmID := range deploymentInfo.VMIDs { vms = append(vms, &metaldv1.GetDeploymentResponse_Vm{ Id: vmID, State: state, Host: service.Spec.ClusterIP, - Port: 8080, + Port: uint32(nodePort), }) } From 0e9db8d15bcdb4abd757ababfe1a659d206dfe34 Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 10 Sep 2025 23:01:58 +0200 Subject: [PATCH 06/20] remove logs --- .../services/deployment/deploy_workflow.go | 44 ++++++++++++++++--- .../ctrl/services/deployment/fallbacks/k8s.go | 18 ++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/go/apps/ctrl/services/deployment/deploy_workflow.go b/go/apps/ctrl/services/deployment/deploy_workflow.go index e833e9d25c..fcf0c1cb8c 100644 --- a/go/apps/ctrl/services/deployment/deploy_workflow.go +++ b/go/apps/ctrl/services/deployment/deploy_workflow.go @@ -190,29 +190,57 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro for _, instance := range vms { known, ok := instances[instance.Id] if !ok || known.State != instance.State { - if err := partitiondb.Query.UpsertVM(stepCtx, w.partitionDB.RW(), partitiondb.UpsertVMParams{ + upsertParams := partitiondb.UpsertVMParams{ ID: instance.Id, DeploymentID: req.DeploymentID, Address: sql.NullString{Valid: true, String: fmt.Sprintf("%s:%d", instance.Host, instance.Port)}, CpuMillicores: 1000, // TODO derive from spec MemoryMb: 1024, // TODO derive from spec Status: partitiondb.VmsStatusRunning, // TODO - }); err != nil { - w.logger.Error("failed to upsert VM", "error", err, "vm_id", instance.Id) + } + + w.logger.Info("upserting VM to database", + "vm_id", instance.Id, + "deployment_id", req.DeploymentID, + "address", fmt.Sprintf("%s:%d", instance.Host, instance.Port), + "status", "running") + + if err := partitiondb.Query.UpsertVM(stepCtx, w.partitionDB.RW(), upsertParams); err != nil { + w.logger.Error("failed to upsert VM", "error", err, "vm_id", instance.Id, "params", upsertParams) return nil, fmt.Errorf("failed to upsert VM %s: %w", instance.Id, err) } + w.logger.Info("successfully upserted VM to database", "vm_id", instance.Id) + instances[instance.Id] = instance - if instance.State != metaldv1.VmState_VM_STATE_RUNNING { - allReady = false - w.logger.Debug("vm state changed", "vm_id", instance.Id, "state", instance.State) - } + } + + w.logger.Debug("checking VM readiness", "vm_id", instance.Id, "state", instance.State.String()) + if instance.State != metaldv1.VmState_VM_STATE_RUNNING { + allReady = false + w.logger.Debug("vm not ready", "vm_id", instance.Id, "state", instance.State.String()) } } if allReady { + w.logger.Info("all VMs ready, deployment complete", + "deployment_id", req.DeploymentID, + "vm_count", len(vms), + "vms", func() []string { + var ids []string + for _, vm := range vms { + ids = append(ids, vm.Id) + } + return ids + }()) + return vms, nil } + + w.logger.Debug("deployment not ready yet, continuing to poll", + "deployment_id", req.DeploymentID, + "iteration", i, + "all_ready", allReady) } return nil, fmt.Errorf("deployment never became ready") @@ -366,6 +394,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro // Update deployment status to active _, err = hydra.Step(ctx, "update-deployment-ready", func(stepCtx context.Context) (*DeploymentResult, error) { + w.logger.Info("updating deployment status to ready", "deployment_id", req.DeploymentID) completionTime := time.Now().UnixMilli() activeErr := db.Query.UpdateDeploymentStatus(stepCtx, w.db.RW(), db.UpdateDeploymentStatusParams{ ID: req.DeploymentID, @@ -376,6 +405,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro w.logger.Error("failed to update deployment status to ready", "error", activeErr, "deployment_id", req.DeploymentID) return nil, fmt.Errorf("failed to update deployment status to ready: %w", activeErr) } + w.logger.Info("deployment status updated to ready", "deployment_id", req.DeploymentID) return &DeploymentResult{ DeploymentID: req.DeploymentID, diff --git a/go/apps/ctrl/services/deployment/fallbacks/k8s.go b/go/apps/ctrl/services/deployment/fallbacks/k8s.go index 5f0df52018..4871f5c417 100644 --- a/go/apps/ctrl/services/deployment/fallbacks/k8s.go +++ b/go/apps/ctrl/services/deployment/fallbacks/k8s.go @@ -233,20 +233,20 @@ func (k *K8sBackend) GetDeploymentStatus(ctx context.Context, deploymentID strin state = metaldv1.VmState_VM_STATE_CREATED } - // Get the NodePort from the service - nodePort := int32(8080) // fallback - if len(service.Spec.Ports) > 0 && service.Spec.Ports[0].NodePort != 0 { - nodePort = service.Spec.Ports[0].NodePort - } + // Always use cluster IP and container port for backend communication + host := service.Spec.ClusterIP + port := int32(8080) // Always use container port for backend service calls // For each VM ID, create a response for _, vmID := range deploymentInfo.VMIDs { - vms = append(vms, &metaldv1.GetDeploymentResponse_Vm{ + vm := &metaldv1.GetDeploymentResponse_Vm{ Id: vmID, State: state, - Host: service.Spec.ClusterIP, - Port: uint32(nodePort), - }) + Host: host, + Port: uint32(port), + } + + vms = append(vms, vm) } return vms, nil From 71806b0084ff1cde192b07a1a5e3d0cb93dbf797 Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 10 Sep 2025 23:33:54 +0200 Subject: [PATCH 07/20] uh sure --- go/apps/gw/router/register.go | 15 ++++++++------- go/apps/gw/router/services.go | 1 + go/apps/gw/run.go | 2 ++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/go/apps/gw/router/register.go b/go/apps/gw/router/register.go index 8c4edcbdd0..126e1b3314 100644 --- a/go/apps/gw/router/register.go +++ b/go/apps/gw/router/register.go @@ -106,13 +106,14 @@ func Register(srv *server.Server, svc *Services, region string, serverType Serve // ACME challenge endpoint mux.Handle("/.well-known/acme-challenge/", srv.WrapHandler(acmeHandler.Handle, defaultMiddlewares)) - // Redirect all other HTTP traffic to HTTPS - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - // Build HTTPS URL - httpsURL := "https://" + r.Host + r.URL.RequestURI() - // Use 307 to preserve method and body - http.Redirect(w, r, httpsURL, http.StatusTemporaryRedirect) - }) + // For testing or other reasons we want to bypass HTTPS redirection + if svc.HttpProxy { + mux.Handle("/", srv.WrapHandler(proxyHandler.Handle, defaultMiddlewares)) + } else { + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://"+r.Host+r.URL.RequestURI(), http.StatusTemporaryRedirect) + }) + } } // HTTPS server configuration for main gateway diff --git a/go/apps/gw/router/services.go b/go/apps/gw/router/services.go index b7face1863..2ef5e3c1ce 100644 --- a/go/apps/gw/router/services.go +++ b/go/apps/gw/router/services.go @@ -22,4 +22,5 @@ type Services struct { Ratelimit ratelimit.Service MainDomain string // Main gateway domain for internal endpoints AcmeClient ctrlv1connect.AcmeServiceClient + HttpProxy bool // Whether to handle proxy requests directly over HTTP } diff --git a/go/apps/gw/run.go b/go/apps/gw/run.go index b9daba01bb..c5058b9e81 100644 --- a/go/apps/gw/run.go +++ b/go/apps/gw/run.go @@ -275,6 +275,8 @@ func Run(ctx context.Context, cfg Config) error { Ratelimit: nil, MainDomain: cfg.MainDomain, AcmeClient: acmeClient, + // For now just enable it if we don't do SSL Termination + HttpProxy: !cfg.EnableTLS, } // Register routes for HTTP server (ACME challenges) From 1d84ae6d6318d9600841b9a849e989e50f64d78e Mon Sep 17 00:00:00 2001 From: Flo Date: Thu, 11 Sep 2025 10:31:28 +0200 Subject: [PATCH 08/20] make the rabbit happy --- go/apps/ctrl/config.go | 9 ++- go/apps/ctrl/run.go | 10 +-- .../{backend.go => backend_adapter.go} | 53 +++++++++---- .../{fallbacks => backends}/docker.go | 29 ++++--- .../services/deployment/backends/interface.go | 69 +++++++++++++++++ .../deployment/{fallbacks => backends}/k8s.go | 73 +++++++++++++++--- .../services/deployment/deploy_workflow.go | 62 +++++++++------ .../deployment/fallbacks/interface.go | 36 --------- go/apps/gw/router/register.go | 3 +- go/cmd/ctrl/main.go | 6 +- go/cmd/deploy/build_docker.go | 76 +++++++++++++------ go/cmd/gw/main.go | 6 +- go/go.mod | 11 +-- go/go.sum | 22 ++++++ go/pkg/db/plugins/bulk-insert/README.md | 4 +- go/pkg/db/plugins/bulk-insert/generator.go | 4 +- go/pkg/db/querier_bulk_generated.go | 2 +- .../db/bulk_gateway_upsert.sql_generated.go | 4 +- .../db/gateway_upsert.sql_generated.go | 10 ++- go/pkg/partition/db/querier_bulk_generated.go | 2 +- go/pkg/partition/db/querier_generated.go | 5 +- .../partition/db/queries/gateway_upsert.sql | 5 +- 22 files changed, 354 insertions(+), 147 deletions(-) rename go/apps/ctrl/services/deployment/{backend.go => backend_adapter.go} (63%) rename go/apps/ctrl/services/deployment/{fallbacks => backends}/docker.go (88%) create mode 100644 go/apps/ctrl/services/deployment/backends/interface.go rename go/apps/ctrl/services/deployment/{fallbacks => backends}/k8s.go (76%) delete mode 100644 go/apps/ctrl/services/deployment/fallbacks/interface.go diff --git a/go/apps/ctrl/config.go b/go/apps/ctrl/config.go index 6d45849f26..e874590a5f 100644 --- a/go/apps/ctrl/config.go +++ b/go/apps/ctrl/config.go @@ -1,6 +1,9 @@ package ctrl import ( + "fmt" + + "github.com/unkeyed/unkey/go/apps/ctrl/services/deployment/backends" "github.com/unkeyed/unkey/go/pkg/clock" "github.com/unkeyed/unkey/go/pkg/tls" ) @@ -50,7 +53,7 @@ type Config struct { // MetaldAddress is the full URL of the metald service for VM operations (e.g., "https://metald.example.com:8080") MetaldAddress string - MetalDFallback string // fallback to either k8's pod or docker, this skips calling metald + MetaldBackend string // fallback to either k8's pod or docker, this skips calling metald // SPIFFESocketPath is the path to the SPIFFE agent socket for mTLS authentication SPIFFESocketPath string @@ -65,6 +68,10 @@ type Config struct { } func (c Config) Validate() error { + // Validate MetaldBackend field + if err := backends.ValidateBackendType(c.MetaldBackend); err != nil { + return fmt.Errorf("invalid metald backend configuration: %w", err) + } return nil } diff --git a/go/apps/ctrl/run.go b/go/apps/ctrl/run.go index 2d32ad754b..bff312cb34 100644 --- a/go/apps/ctrl/run.go +++ b/go/apps/ctrl/run.go @@ -187,11 +187,11 @@ func Run(ctx context.Context, cfg Config) error { // Register deployment workflow with Hydra worker deployWorkflow := deployment.NewDeployWorkflow(deployment.DeployWorkflowConfig{ - Logger: logger, - DB: database, - PartitionDB: partitionDB, - MetalDFallback: cfg.MetalDFallback, - MetalD: metaldClient, + Logger: logger, + DB: database, + PartitionDB: partitionDB, + MetaldBackend: cfg.MetaldBackend, + MetalD: metaldClient, }) err = hydra.RegisterWorkflow(hydraWorker, deployWorkflow) if err != nil { diff --git a/go/apps/ctrl/services/deployment/backend.go b/go/apps/ctrl/services/deployment/backend_adapter.go similarity index 63% rename from go/apps/ctrl/services/deployment/backend.go rename to go/apps/ctrl/services/deployment/backend_adapter.go index 2a0b3cbe33..3f5b6d3ba1 100644 --- a/go/apps/ctrl/services/deployment/backend.go +++ b/go/apps/ctrl/services/deployment/backend_adapter.go @@ -3,9 +3,10 @@ package deployment import ( "context" "fmt" + "strings" "connectrpc.com/connect" - "github.com/unkeyed/unkey/go/apps/ctrl/services/deployment/fallbacks" + "github.com/unkeyed/unkey/go/apps/ctrl/services/deployment/backends" metaldv1 "github.com/unkeyed/unkey/go/gen/proto/metald/v1" "github.com/unkeyed/unkey/go/gen/proto/metald/v1/metaldv1connect" "github.com/unkeyed/unkey/go/pkg/otel/logging" @@ -13,6 +14,7 @@ import ( // DeploymentBackend provides a unified interface for deployment operations type DeploymentBackend interface { + Name() string CreateDeployment(ctx context.Context, req *metaldv1.CreateDeploymentRequest) (*metaldv1.CreateDeploymentResponse, error) GetDeployment(ctx context.Context, deploymentID string) ([]*metaldv1.GetDeploymentResponse_Vm, error) } @@ -30,6 +32,10 @@ func NewMetalDBackend(client metaldv1connect.VmServiceClient, logger logging.Log } } +func (m *MetalDBackend) Name() string { + return "metald" +} + func (m *MetalDBackend) CreateDeployment(ctx context.Context, req *metaldv1.CreateDeploymentRequest) (*metaldv1.CreateDeploymentResponse, error) { resp, err := m.client.CreateDeployment(ctx, connect.NewRequest(req)) if err != nil { @@ -48,36 +54,49 @@ func (m *MetalDBackend) GetDeployment(ctx context.Context, deploymentID string) return resp.Msg.GetVms(), nil } -// FallbackBackend wraps a fallback backend to implement the unified DeploymentBackend interface -type FallbackBackend struct { - backend fallbacks.DeploymentBackend +// LocalBackendAdapter wraps a local backend to implement the unified DeploymentBackend interface +type LocalBackendAdapter struct { + backend backends.DeploymentBackend logger logging.Logger } -func NewFallbackBackend(backendType string, logger logging.Logger) (*FallbackBackend, error) { - backend, err := fallbacks.NewBackend(backendType, logger) +func NewLocalBackendAdapter(backendType string, logger logging.Logger) (*LocalBackendAdapter, error) { + backend, err := backends.NewBackend(backendType, logger) if err != nil { return nil, err } - return &FallbackBackend{ + return &LocalBackendAdapter{ backend: backend, logger: logger, }, nil } -func (f *FallbackBackend) CreateDeployment(ctx context.Context, req *metaldv1.CreateDeploymentRequest) (*metaldv1.CreateDeploymentResponse, error) { +func (f *LocalBackendAdapter) CreateDeployment(ctx context.Context, req *metaldv1.CreateDeploymentRequest) (*metaldv1.CreateDeploymentResponse, error) { deployment := req.GetDeployment() if deployment == nil { return nil, fmt.Errorf("deployment request is nil") } + // Validate image + image := strings.TrimSpace(deployment.GetImage()) + if image == "" { + return nil, fmt.Errorf("deployment image cannot be empty or whitespace") + } + + // Validate VM count + vmCount := deployment.GetVmCount() + if vmCount <= 0 { + return nil, fmt.Errorf("deployment VM count must be greater than 0, got %d", vmCount) + } + vmIDs, err := f.backend.CreateDeployment(ctx, deployment.GetDeploymentId(), - deployment.GetImage(), - int32(deployment.GetVmCount())) + image, + vmCount, + ) if err != nil { - return nil, fmt.Errorf("fallback CreateDeployment failed: %w", err) + return nil, fmt.Errorf("local backend CreateDeployment failed: %w", err) } return &metaldv1.CreateDeploymentResponse{ @@ -85,19 +104,23 @@ func (f *FallbackBackend) CreateDeployment(ctx context.Context, req *metaldv1.Cr }, nil } -func (f *FallbackBackend) GetDeployment(ctx context.Context, deploymentID string) ([]*metaldv1.GetDeploymentResponse_Vm, error) { +func (f *LocalBackendAdapter) GetDeployment(ctx context.Context, deploymentID string) ([]*metaldv1.GetDeploymentResponse_Vm, error) { vms, err := f.backend.GetDeploymentStatus(ctx, deploymentID) if err != nil { - return nil, fmt.Errorf("fallback GetDeploymentStatus failed: %w", err) + return nil, fmt.Errorf("local backend GetDeploymentStatus failed: %w", err) } return vms, nil } +func (f *LocalBackendAdapter) Name() string { + return f.backend.Type() +} + // NewDeploymentBackend creates the appropriate backend based on configuration func NewDeploymentBackend(metalDClient metaldv1connect.VmServiceClient, fallbackType string, logger logging.Logger) (DeploymentBackend, error) { if fallbackType != "" { - logger.Info("using fallback deployment backend", "type", fallbackType) - return NewFallbackBackend(fallbackType, logger) + logger.Info("using local deployment backend", "type", fallbackType) + return NewLocalBackendAdapter(fallbackType, logger) } if metalDClient == nil { diff --git a/go/apps/ctrl/services/deployment/fallbacks/docker.go b/go/apps/ctrl/services/deployment/backends/docker.go similarity index 88% rename from go/apps/ctrl/services/deployment/fallbacks/docker.go rename to go/apps/ctrl/services/deployment/backends/docker.go index f4df738bd6..a4107de451 100644 --- a/go/apps/ctrl/services/deployment/fallbacks/docker.go +++ b/go/apps/ctrl/services/deployment/backends/docker.go @@ -1,4 +1,4 @@ -package fallbacks +package backends import ( "context" @@ -12,6 +12,7 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" "github.com/docker/docker/client" + "github.com/docker/docker/errdefs" "github.com/docker/go-connections/nat" metaldv1 "github.com/unkeyed/unkey/go/gen/proto/metald/v1" "github.com/unkeyed/unkey/go/pkg/otel/logging" @@ -60,7 +61,7 @@ func NewDockerBackend(logger logging.Logger) (*DockerBackend, error) { } // CreateDeployment creates Docker containers for the deployment -func (d *DockerBackend) CreateDeployment(ctx context.Context, deploymentID string, image string, vmCount int32) ([]string, error) { +func (d *DockerBackend) CreateDeployment(ctx context.Context, deploymentID string, image string, vmCount uint32) ([]string, error) { d.logger.Info("creating Docker deployment", "deployment_id", deploymentID, "image", image, @@ -80,7 +81,7 @@ func (d *DockerBackend) CreateDeployment(ctx context.Context, deploymentID strin } // Create containers - for i := int32(0); i < vmCount; i++ { + for i := range vmCount { vmID := uid.New("vm") containerName := fmt.Sprintf("unkey-%s-%s", deploymentID, vmID) @@ -191,28 +192,36 @@ func (d *DockerBackend) DeleteDeployment(ctx context.Context, deploymentID strin // Type returns the backend type func (d *DockerBackend) Type() string { - return "docker" + return BackendTypeDocker } // Helper methods func (d *DockerBackend) pullImageIfNeeded(ctx context.Context, imageName string) error { // Check if image exists locally - _, _, err := d.dockerClient.ImageInspectWithRaw(ctx, imageName) - if err == nil { + _, _, inspectErr := d.dockerClient.ImageInspectWithRaw(ctx, imageName) + if inspectErr == nil { d.logger.Info("image found locally", "image", imageName) return nil } + // Only attempt to pull if the error indicates the image is not found + // For other errors (e.g., permission issues, Docker daemon problems), return immediately + if !errdefs.IsNotFound(inspectErr) { + return fmt.Errorf("failed to inspect image %s: %w", imageName, inspectErr) + } + + // Image not found locally, attempt to pull it d.logger.Info("pulling image", "image", imageName) - reader, err := d.dockerClient.ImagePull(ctx, imageName, image.PullOptions{}) - if err != nil { - return fmt.Errorf("failed to pull image %s: %w", imageName, err) + reader, pullErr := d.dockerClient.ImagePull(ctx, imageName, image.PullOptions{}) + if pullErr != nil { + // If pull fails, return the original inspect error for better context + return fmt.Errorf("image %s not found locally and pull failed: inspect error: %v, pull error: %w", imageName, inspectErr, pullErr) } defer reader.Close() // Read the output to ensure pull completes - _, err = io.Copy(io.Discard, reader) + _, err := io.Copy(io.Discard, reader) if err != nil { return fmt.Errorf("failed to read pull response: %w", err) } diff --git a/go/apps/ctrl/services/deployment/backends/interface.go b/go/apps/ctrl/services/deployment/backends/interface.go new file mode 100644 index 0000000000..c9349cc5f5 --- /dev/null +++ b/go/apps/ctrl/services/deployment/backends/interface.go @@ -0,0 +1,69 @@ +package backends + +import ( + "context" + "fmt" + "strings" + + metaldv1 "github.com/unkeyed/unkey/go/gen/proto/metald/v1" + "github.com/unkeyed/unkey/go/pkg/otel/logging" +) + +// Backend type constants +const ( + BackendTypeK8s = "k8s" + BackendTypeDocker = "docker" +) + +// DeploymentBackend defines the interface for deployment backends +type DeploymentBackend interface { + // CreateDeployment creates a new deployment and returns VM IDs + CreateDeployment(ctx context.Context, deploymentID string, image string, vmCount uint32) ([]string, error) + + // GetDeploymentStatus checks if deployment VMs are ready + GetDeploymentStatus(ctx context.Context, deploymentID string) ([]*metaldv1.GetDeploymentResponse_Vm, error) + + // DeleteDeployment removes a deployment and its resources + DeleteDeployment(ctx context.Context, deploymentID string) error + + // Type returns the backend type name + Type() string +} + +// ValidateBackendType checks if a backend type is valid +// Returns nil if valid, error if invalid +func ValidateBackendType(backendType string) error { + if backendType == "" { + return nil // Empty string is valid (means no fallback) + } + + // Normalize backend type to lowercase for case-insensitive comparison + normalizedType := strings.ToLower(strings.TrimSpace(backendType)) + + switch normalizedType { + case BackendTypeK8s, BackendTypeDocker: + return nil + default: + return fmt.Errorf("unsupported backend type: %s; allowed values: %q, %q, or \"\" (empty)", backendType, BackendTypeK8s, BackendTypeDocker) + } +} + +// NewBackend creates a new deployment backend based on the specified type +func NewBackend(backendType string, logger logging.Logger) (DeploymentBackend, error) { + // Validate backend type first + if err := ValidateBackendType(backendType); err != nil { + return nil, err + } + + // Normalize backend type to lowercase for case-insensitive comparison + normalizedType := strings.ToLower(strings.TrimSpace(backendType)) + + switch normalizedType { + case BackendTypeK8s: + return NewK8sBackend(logger) + case BackendTypeDocker: + return NewDockerBackend(logger) + default: + return nil, fmt.Errorf("unsupported backend type: %s; allowed values: %q, %q", backendType, BackendTypeK8s, BackendTypeDocker) + } +} diff --git a/go/apps/ctrl/services/deployment/fallbacks/k8s.go b/go/apps/ctrl/services/deployment/backends/k8s.go similarity index 76% rename from go/apps/ctrl/services/deployment/fallbacks/k8s.go rename to go/apps/ctrl/services/deployment/backends/k8s.go index 4871f5c417..28c082be63 100644 --- a/go/apps/ctrl/services/deployment/fallbacks/k8s.go +++ b/go/apps/ctrl/services/deployment/backends/k8s.go @@ -1,9 +1,11 @@ -package fallbacks +package backends import ( "context" + "crypto/sha256" "fmt" "os" + "regexp" "strings" "sync" "time" @@ -18,6 +20,7 @@ import ( metaldv1 "github.com/unkeyed/unkey/go/gen/proto/metald/v1" "github.com/unkeyed/unkey/go/pkg/otel/logging" + "github.com/unkeyed/unkey/go/pkg/ptr" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -68,7 +71,7 @@ func NewK8sBackend(logger logging.Logger) (*K8sBackend, error) { } // CreateDeployment creates Kubernetes pods for the deployment -func (k *K8sBackend) CreateDeployment(ctx context.Context, deploymentID string, image string, vmCount int32) ([]string, error) { +func (k *K8sBackend) CreateDeployment(ctx context.Context, deploymentID string, image string, vmCount uint32) ([]string, error) { k.logger.Info("creating Kubernetes deployment", "deployment_id", deploymentID, "image", image, @@ -76,16 +79,12 @@ func (k *K8sBackend) CreateDeployment(ctx context.Context, deploymentID string, "namespace", k.namespace) vmIDs := make([]string, vmCount) - for i := int32(0); i < vmCount; i++ { + for i := range vmCount { vmIDs[i] = uid.New("vm") } // Sanitize deployment ID for Kubernetes RFC 1123 compliance - // Must be lowercase, alphanumeric, and hyphens only - sanitizedDeploymentID := strings.ReplaceAll(deploymentID, "_", "-") - sanitizedDeploymentID = strings.ToLower(sanitizedDeploymentID) - deploymentName := fmt.Sprintf("unkey-%s", sanitizedDeploymentID) - serviceName := fmt.Sprintf("unkey-svc-%s", sanitizedDeploymentID) + deploymentName, serviceName := k.sanitizeK8sNames(deploymentID) // Create deployment deployment := &appsv1.Deployment{ @@ -98,7 +97,7 @@ func (k *K8sBackend) CreateDeployment(ctx context.Context, deploymentID string, }, }, Spec: appsv1.DeploymentSpec{ - Replicas: &vmCount, + Replicas: ptr.P(int32(vmCount)), Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{ "unkey.deployment.id": deploymentID, @@ -290,7 +289,59 @@ func (k *K8sBackend) DeleteDeployment(ctx context.Context, deploymentID string) // Type returns the backend type func (k *K8sBackend) Type() string { - return "k8s" + return BackendTypeK8s +} + +// sanitizeK8sNames generates RFC1123-compliant names for Kubernetes resources +// It ensures names are lowercase, alphanumeric with hyphens, max 63 chars, and unique +func (k *K8sBackend) sanitizeK8sNames(deploymentID string) (deploymentName, serviceName string) { + // Generate a short hash suffix for uniqueness (6 hex chars) + hash := sha256.Sum256([]byte(deploymentID)) + hashSuffix := fmt.Sprintf("%x", hash[:3]) // 3 bytes = 6 hex chars + + // Replace any character not in [a-z0-9-] with a hyphen + // This regex will be compiled once at startup for efficiency + invalidCharsRegex := regexp.MustCompile(`[^a-z0-9-]+`) + sanitized := invalidCharsRegex.ReplaceAllString(strings.ToLower(deploymentID), "-") + + // Collapse consecutive hyphens to a single hyphen + multiHyphenRegex := regexp.MustCompile(`-+`) + sanitized = multiHyphenRegex.ReplaceAllString(sanitized, "-") + + // Trim leading and trailing hyphens + sanitized = strings.Trim(sanitized, "-") + + // If empty after sanitization, use a default + if sanitized == "" { + sanitized = "deployment" + } + + // Calculate max lengths for the core ID part + // Format: "unkey--" (deployment) and "unkey-svc--" (service) + // Max total length is 63 chars + // Reserve: "unkey-" (6) + "-" (1) + hash (6) = 13 chars for deployment + // Reserve: "unkey-svc-" (10) + "-" (1) + hash (6) = 17 chars for service + maxDeploymentCore := 63 - 13 // = 50 chars + maxServiceCore := 63 - 17 // = 46 chars + + // Use the smaller limit to ensure both names are valid + maxCore := min(maxDeploymentCore, maxServiceCore) + if len(sanitized) > maxCore { + sanitized = sanitized[:maxCore] + // Trim any trailing hyphen from truncation + sanitized = strings.TrimRight(sanitized, "-") + } + + // Build the final names with hash suffix + deploymentName = fmt.Sprintf("unkey-%s-%s", sanitized, hashSuffix) + serviceName = fmt.Sprintf("unkey-svc-%s-%s", sanitized, hashSuffix) + + // Final validation: ensure names start and end with alphanumeric + // (should already be the case, but double-check) + deploymentName = strings.Trim(deploymentName, "-") + serviceName = strings.Trim(serviceName, "-") + + return deploymentName, serviceName } // Helper function to read namespace from service account @@ -299,5 +350,5 @@ func readNamespaceFromServiceAccount() (string, error) { if err != nil { return "", err } - return string(data), nil + return strings.TrimSpace(string(data)), nil } diff --git a/go/apps/ctrl/services/deployment/deploy_workflow.go b/go/apps/ctrl/services/deployment/deploy_workflow.go index fcf0c1cb8c..89530c6a9f 100644 --- a/go/apps/ctrl/services/deployment/deploy_workflow.go +++ b/go/apps/ctrl/services/deployment/deploy_workflow.go @@ -28,22 +28,22 @@ type DeployWorkflow struct { } type DeployWorkflowConfig struct { - Logger logging.Logger - DB db.Database - PartitionDB db.Database - MetalD metaldv1connect.VmServiceClient - MetalDFallback string + Logger logging.Logger + DB db.Database + PartitionDB db.Database + MetalD metaldv1connect.VmServiceClient + MetaldBackend string } // NewDeployWorkflow creates a new deploy workflow instance func NewDeployWorkflow(cfg DeployWorkflowConfig) *DeployWorkflow { // Create the appropriate deployment backend - deploymentBackend, err := NewDeploymentBackend(cfg.MetalD, cfg.MetalDFallback, cfg.Logger) + deploymentBackend, err := NewDeploymentBackend(cfg.MetalD, cfg.MetaldBackend, cfg.Logger) if err != nil { // Log error but continue - workflow will fail when trying to use the backend cfg.Logger.Error("failed to initialize deployment backend", "error", err, - "fallback", cfg.MetalDFallback) + "fallback", cfg.MetaldBackend) } return &DeployWorkflow{ @@ -118,8 +118,13 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return err } - deployment, err := hydra.Step(ctx, "metald-create-deployment", func(stepCtx context.Context) (*metaldv1.CreateDeploymentResponse, error) { + // Get backend name for step naming + backendName := "unknown" + if w.deploymentBackend != nil { + backendName = w.deploymentBackend.Name() + } + deployment, err := hydra.Step(ctx, fmt.Sprintf("%s-create-deployment", backendName), func(stepCtx context.Context) (*metaldv1.CreateDeploymentResponse, error) { if w.deploymentBackend == nil { return nil, fmt.Errorf("deployment backend not initialized") } @@ -144,7 +149,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return resp, nil }) if err != nil { - w.logger.Error("Deployment failed", "error", err, "deployment_id", req.DeploymentID) + w.logger.Error("Deployment failed", "error", err, "deployment_id", req.DeploymentID, "backend", backendName) return err } @@ -374,12 +379,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro } } - w.logger.Info("gateway configurations created in bulk", - "deployment_id", req.DeploymentID, - "total_domains", len(allDomains), - "configs_created", len(gatewayParams), - "skipped_domains", skippedDomains) - return db.Query.InsertDeploymentStep(stepCtx, w.db.RW(), db.InsertDeploymentStepParams{ DeploymentID: req.DeploymentID, Status: db.DeploymentStepsStatusAssigningDomains, @@ -392,7 +391,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return err } - // Update deployment status to active + // Update deployment status to ready _, err = hydra.Step(ctx, "update-deployment-ready", func(stepCtx context.Context) (*DeploymentResult, error) { w.logger.Info("updating deployment status to ready", "deployment_id", req.DeploymentID) completionTime := time.Now().UnixMilli() @@ -500,8 +499,9 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro } // Unmarshal existing config + // IMPORTANT: Gateway configs are stored as JSON in the database for compatibility with the gateway service var gatewayConfig partitionv1.GatewayConfig - if err := proto.Unmarshal(existingConfig.Config, &gatewayConfig); err != nil { + if err := protojson.Unmarshal(existingConfig.Config, &gatewayConfig); err != nil { w.logger.Error("failed to unmarshal existing gateway config", "error", err, "hostname", req.Hostname) return fmt.Errorf("failed to unmarshal existing gateway config: %w", err) } @@ -513,7 +513,8 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro gatewayConfig.ValidationConfig.OpenapiSpec = openapiSpec // Marshal updated config - configBytes, err := proto.Marshal(&gatewayConfig) + // Gateway configs must be stored as JSON for compatibility with the gateway service + configBytes, err := protojson.Marshal(&gatewayConfig) if err != nil { w.logger.Error("failed to marshal updated gateway config", "error", err) return fmt.Errorf("failed to marshal updated gateway config: %w", err) @@ -588,6 +589,12 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro } // createGatewayConfig creates a gateway configuration protobuf object +// +// ENCODING POLICY FOR GATEWAY CONFIGS: +// Gateway configs are stored as JSON (using protojson.Marshal) for easier debugging +// and readability during development/demo. This makes it simpler to inspect and +// modify configs directly in the database. +// IMPORTANT: Always use protojson.Marshal for writes and protojson.Unmarshal for reads. func (w *DeployWorkflow) createGatewayConfig(deploymentID, keyspaceID string, vms []*metaldv1.GetDeploymentResponse_Vm) (*partitionv1.GatewayConfig, error) { // Create VM protobuf objects for gateway config gatewayConfig := &partitionv1.GatewayConfig{ @@ -652,16 +659,23 @@ func (w *DeployWorkflow) createGatewayConfigForHostname(ctx context.Context, wor // isLocalHostname checks if a hostname is for local development func isLocalHostname(hostname string) bool { - localDomains := []string{ - "localhost", - "127.0.0.1", + // Lowercase for case-insensitive comparison + hostname = strings.ToLower(hostname) + + // Exact matches for common local hosts + if hostname == "localhost" || hostname == "127.0.0.1" { + return true + } + + // Check for local-only TLD suffixes + // Note: .dev is a real TLD owned by Google, so it's excluded + localSuffixes := []string{ ".local", - ".dev", ".test", } - for _, local := range localDomains { - if strings.Contains(hostname, local) { + for _, suffix := range localSuffixes { + if strings.HasSuffix(hostname, suffix) { return true } } diff --git a/go/apps/ctrl/services/deployment/fallbacks/interface.go b/go/apps/ctrl/services/deployment/fallbacks/interface.go deleted file mode 100644 index 114e5ab520..0000000000 --- a/go/apps/ctrl/services/deployment/fallbacks/interface.go +++ /dev/null @@ -1,36 +0,0 @@ -package fallbacks - -import ( - "context" - "fmt" - - metaldv1 "github.com/unkeyed/unkey/go/gen/proto/metald/v1" - "github.com/unkeyed/unkey/go/pkg/otel/logging" -) - -// DeploymentBackend defines the interface for deployment backends -type DeploymentBackend interface { - // CreateDeployment creates a new deployment and returns VM IDs - CreateDeployment(ctx context.Context, deploymentID string, image string, vmCount int32) ([]string, error) - - // GetDeploymentStatus checks if deployment VMs are ready - GetDeploymentStatus(ctx context.Context, deploymentID string) ([]*metaldv1.GetDeploymentResponse_Vm, error) - - // DeleteDeployment removes a deployment and its resources - DeleteDeployment(ctx context.Context, deploymentID string) error - - // Type returns the backend type name - Type() string -} - -// NewBackend creates a new deployment backend based on the specified type -func NewBackend(backendType string, logger logging.Logger) (DeploymentBackend, error) { - switch backendType { - case "k8s": - return NewK8sBackend(logger) - case "docker": - return NewDockerBackend(logger) - default: - return nil, fmt.Errorf("unsupported backend type: %s", backendType) - } -} diff --git a/go/apps/gw/router/register.go b/go/apps/gw/router/register.go index 126e1b3314..cf1a0b3ebd 100644 --- a/go/apps/gw/router/register.go +++ b/go/apps/gw/router/register.go @@ -108,10 +108,11 @@ func Register(srv *server.Server, svc *Services, region string, serverType Serve // For testing or other reasons we want to bypass HTTPS redirection if svc.HttpProxy { + svc.Logger.Error("Plaintext HTTP proxying is ENABLED! This should ONLY be used for testing/development.") mux.Handle("/", srv.WrapHandler(proxyHandler.Handle, defaultMiddlewares)) } else { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "https://"+r.Host+r.URL.RequestURI(), http.StatusTemporaryRedirect) + http.Redirect(w, r, "https://"+r.Host+r.URL.RequestURI(), http.StatusPermanentRedirect) }) } } diff --git a/go/cmd/ctrl/main.go b/go/cmd/ctrl/main.go index a705d75c98..5978b6163d 100644 --- a/go/cmd/ctrl/main.go +++ b/go/cmd/ctrl/main.go @@ -72,7 +72,7 @@ var Cmd = &cli.Command{ cli.Required(), cli.EnvVar("UNKEY_VAULT_S3_ACCESS_KEY_SECRET")), cli.Bool("acme-enabled", "Enable Let's Encrypt for acme challenges", cli.EnvVar("UNKEY_ACME_ENABLED")), - cli.String("metalD-fallback", "Whether to call metalD or go to the fallback (docker or k8s)", cli.EnvVar("UNKEY_METALD_FALLBACK")), + cli.String("metald-backend", "Whether to call metalD or go to a fallback (docker or k8s)", cli.EnvVar("UNKEY_METALD_BACKEND")), }, Action: action, } @@ -129,8 +129,8 @@ func action(ctx context.Context, cmd *cli.Command) error { AccessKeyID: cmd.String("vault-s3-access-key-id"), }, - AcmeEnabled: cmd.Bool("acme-enabled"), - MetalDFallback: cmd.String("metalD-fallback"), + AcmeEnabled: cmd.Bool("acme-enabled"), + MetaldBackend: cmd.String("metald-backend"), // Common Clock: clock.New(), diff --git a/go/cmd/deploy/build_docker.go b/go/cmd/deploy/build_docker.go index 7aa20e4b8e..670fa0fc1f 100644 --- a/go/cmd/deploy/build_docker.go +++ b/go/cmd/deploy/build_docker.go @@ -39,43 +39,51 @@ var ( ErrBuildTimeout = errors.New("docker build timed out") ) +// Precompiled regexes for Docker tag sanitization +var ( + // reInvalid matches any characters not allowed in Docker tags (after normalization to lowercase) + reInvalid = regexp.MustCompile(`[^a-z0-9._-]+`) + + // reFirstOK matches valid first characters for Docker tags (after normalization) + reFirstOK = regexp.MustCompile(`^[a-z0-9_]`) + + // reMultiDash matches consecutive dashes + reMultiDash = regexp.MustCompile(`-{2,}`) + + // reFinal validates the complete tag format (after normalization) + reFinal = regexp.MustCompile(`^[a-z0-9_][a-z0-9._-]*$`) +) + // sanitizeDockerTag sanitizes a string to be valid for Docker tags // Official Docker tag grammar: /[\w][\w.-]{0,127}/ // - First char: word character (a-zA-Z0-9_) // - Remaining chars: word characters, periods, or dashes -// - Maximum 128 characters total +// - Maximum 128 characters total (enforced later in generateImageTag) +// - Lowercasing is a policy decision to ensure consistency (Docker tags may be mixed-case) func sanitizeDockerTag(input string) string { if input == "" { return "main" } - // Convert to lowercase (Docker registries are case-insensitive) + // Convert to lowercase as a policy decision for consistency + // (Docker tags may be mixed-case but we normalize for predictability) result := strings.ToLower(input) - // Replace invalid characters with dashes - // Keep only: a-z, A-Z, 0-9, _, ., - - invalidChars := regexp.MustCompile(`[^a-zA-Z0-9._-]+`) - result = invalidChars.ReplaceAllString(result, "-") + // Replace invalid characters with dashes using precompiled regex + // Keep only: a-z, 0-9, _, ., - + result = reInvalid.ReplaceAllString(result, "-") - // Ensure first character is a word character (a-zA-Z0-9_) + // Ensure first character is a word character (a-z0-9_) // If it starts with . or -, prepend with a valid character - if len(result) > 0 && !regexp.MustCompile(`^[a-zA-Z0-9_]`).MatchString(result) { + if len(result) > 0 && !reFirstOK.MatchString(result) { result = "v" + result } - // Remove consecutive dashes for cleaner tags - multiDash := regexp.MustCompile(`-{2,}`) - result = multiDash.ReplaceAllString(result, "-") + // Remove consecutive dashes for cleaner tags using precompiled regex + result = reMultiDash.ReplaceAllString(result, "-") - // Limit to 64 characters (leaving room for SHA suffix in the full image tag) - if len(result) > 64 { - result = result[:64] - // Ensure it doesn't end with dash after truncation - result = strings.TrimRight(result, "-") - } - - // Final safety check - ensure we have a valid tag - if result == "" || !regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9._-]*$`).MatchString(result) { + // Final safety check - ensure we have a valid tag using precompiled regex + if result == "" || !reFinal.MatchString(result) { return "main" } @@ -87,10 +95,34 @@ func generateImageTag(opts DeployOptions, gitInfo git.Info) string { // Sanitize branch name for Docker tag compatibility cleanBranch := sanitizeDockerTag(opts.Branch) + // Calculate suffix length to determine maximum branch length + var suffix string + var suffixLen int + if gitInfo.ShortSHA != "" { - return fmt.Sprintf("%s-%s", cleanBranch, gitInfo.ShortSHA) + suffix = gitInfo.ShortSHA + suffixLen = len(suffix) + 1 // +1 for the hyphen + } else { + timestamp := time.Now().Unix() + suffix = fmt.Sprintf("%d", timestamp) + suffixLen = len(suffix) + 1 // +1 for the hyphen } - return fmt.Sprintf("%s-%d", cleanBranch, time.Now().Unix()) + + // Calculate maximum allowed branch length to keep final tag ≤ 128 chars + const maxTagLength = 128 + maxBranchLen := maxTagLength - suffixLen + + // Trim branch to fit within the calculated limit (using rune count for multibyte safety) + branchRunes := []rune(cleanBranch) + if len(branchRunes) > maxBranchLen { + branchRunes = branchRunes[:maxBranchLen] + cleanBranch = string(branchRunes) + // Ensure it doesn't end with a dash after trimming + cleanBranch = strings.TrimRight(cleanBranch, "-") + } + + // Format the final tag + return fmt.Sprintf("%s-%s", cleanBranch, suffix) } // isDockerAvailable checks if Docker is installed and accessible diff --git a/go/cmd/gw/main.go b/go/cmd/gw/main.go index d21f697765..d5e48c6d5c 100644 --- a/go/cmd/gw/main.go +++ b/go/cmd/gw/main.go @@ -14,11 +14,11 @@ var Cmd = &cli.Command{ Usage: "Run the Unkey Gateway server", Flags: []cli.Flag{ // Server Configuration - cli.Int("http-port", "HTTP port for the API server to listen on. Default: 6060", + cli.Int("http-port", "HTTP port for the GW server to listen on. Default: 6060", cli.Default(6060), cli.EnvVar("UNKEY_HTTP_PORT")), - cli.Int("https-port", "HTTP port for the API server to listen on. Default: 6060", - cli.Default(6060), cli.EnvVar("UNKEY_HTTPS_PORT")), + cli.Int("https-port", "HTTPS port for the GW server to listen on. Default: 6433", + cli.Default(6433), cli.EnvVar("UNKEY_HTTPS_PORT")), cli.Bool("tls-enabled", "Enable TLS termination for the gateway. Default: false", cli.Default(false), cli.EnvVar("UNKEY_TLS_ENABLED")), diff --git a/go/go.mod b/go/go.mod index c081431e0c..512dc512c7 100644 --- a/go/go.mod +++ b/go/go.mod @@ -10,6 +10,8 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.17.71 github.com/aws/aws-sdk-go-v2/service/s3 v1.84.1 github.com/btcsuite/btcutil v1.0.2 + github.com/docker/docker v28.4.0+incompatible + github.com/docker/go-connections v0.5.0 github.com/getkin/kin-openapi v0.132.0 github.com/go-acme/lego/v4 v4.25.2 github.com/go-redis/redis/v8 v8.11.5 @@ -43,6 +45,9 @@ require ( golang.org/x/net v0.43.0 golang.org/x/text v0.28.0 google.golang.org/protobuf v1.36.8 + k8s.io/api v0.34.1 + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 ) require ( @@ -80,8 +85,6 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v28.4.0+incompatible // indirect - github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dolthub/maphash v0.1.0 // indirect github.com/dprotaso/go-yit v0.0.0-20250513224043-18a80f8f6df4 // indirect @@ -199,9 +202,7 @@ require ( gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.34.1 // indirect - k8s.io/apimachinery v0.34.1 // indirect - k8s.io/client-go v0.34.1 // indirect + gotest.tools/v3 v3.5.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect diff --git a/go/go.sum b/go/go.sum index cc75d4fa92..eddaa86720 100644 --- a/go/go.sum +++ b/go/go.sum @@ -6,6 +6,8 @@ connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/ClickHouse/ch-go v0.66.0 h1:hLslxxAVb2PHpbHr4n0d6aP8CEIpUYGMVT1Yj/Q5Img= github.com/ClickHouse/ch-go v0.66.0/go.mod h1:noiHWyLMJAZ5wYuq3R/K0TcRhrNA8h7o1AqHX0klEhM= @@ -87,6 +89,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 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/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= @@ -160,6 +164,8 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +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/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -234,6 +240,12 @@ github.com/miekg/dns v1.1.67 h1:kg0EHj0G4bfT5/oOys6HhZw4vmMlnoZ+gDu8tJ/AlI0= github.com/miekg/dns v1.1.67/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 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= @@ -243,6 +255,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWu github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -264,6 +278,8 @@ github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= @@ -329,6 +345,8 @@ github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQU github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/speakeasy-api/jsonpath v0.6.2 h1:Mys71yd6u8kuowNCR0gCVPlVAHCmKtoGXYoAtcEbqXQ= github.com/speakeasy-api/jsonpath v0.6.2/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= @@ -349,6 +367,8 @@ github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8w github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= @@ -567,6 +587,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= diff --git a/go/pkg/db/plugins/bulk-insert/README.md b/go/pkg/db/plugins/bulk-insert/README.md index b6b9710200..0516303433 100644 --- a/go/pkg/db/plugins/bulk-insert/README.md +++ b/go/pkg/db/plugins/bulk-insert/README.md @@ -96,8 +96,8 @@ type BulkQuerier interface { // ... other bulk insert methods } -// Ensure Queries implements BulkQuerier -var _ BulkQuerier = (*Queries)(nil) +// Ensure BulkQueries implements BulkQuerier +var _ BulkQuerier = (*BulkQueries)(nil) ``` ## Usage diff --git a/go/pkg/db/plugins/bulk-insert/generator.go b/go/pkg/db/plugins/bulk-insert/generator.go index a105ec277c..077d25c3b5 100644 --- a/go/pkg/db/plugins/bulk-insert/generator.go +++ b/go/pkg/db/plugins/bulk-insert/generator.go @@ -198,8 +198,8 @@ func (g *Generator) generateInterfaceContent(bulkFunctions []BulkFunction) strin content.WriteString("}\n\n") - // Generate assertion to ensure Queries implements BulkQuerier - content.WriteString("// Ensure Queries implements BulkQuerier\n") + // Generate assertion to ensure BulkQueries implements BulkQuerier + content.WriteString("// Ensure BulkQueries implements BulkQuerier\n") content.WriteString("var _ BulkQuerier = (*BulkQueries)(nil)\n") return content.String() diff --git a/go/pkg/db/querier_bulk_generated.go b/go/pkg/db/querier_bulk_generated.go index 5d6483117b..c70b034bf8 100644 --- a/go/pkg/db/querier_bulk_generated.go +++ b/go/pkg/db/querier_bulk_generated.go @@ -31,5 +31,5 @@ type BulkQuerier interface { InsertWorkspaces(ctx context.Context, db DBTX, args []InsertWorkspaceParams) error } -// Ensure Queries implements BulkQuerier +// Ensure BulkQueries implements BulkQuerier var _ BulkQuerier = (*BulkQueries)(nil) diff --git a/go/pkg/partition/db/bulk_gateway_upsert.sql_generated.go b/go/pkg/partition/db/bulk_gateway_upsert.sql_generated.go index 4adfb43adf..5c005e8c99 100644 --- a/go/pkg/partition/db/bulk_gateway_upsert.sql_generated.go +++ b/go/pkg/partition/db/bulk_gateway_upsert.sql_generated.go @@ -9,7 +9,9 @@ import ( ) // bulkUpsertGateway is the base query for bulk insert -const bulkUpsertGateway = `INSERT INTO gateways (workspace_id, hostname, config) VALUES %s ON DUPLICATE KEY UPDATE config = VALUES(config)` +const bulkUpsertGateway = `INSERT INTO gateways (workspace_id, hostname, config) VALUES %s ON DUPLICATE KEY UPDATE + config = VALUES(config), + workspace_id = VALUES(workspace_id)` // UpsertGateway performs bulk insert in a single query func (q *BulkQueries) UpsertGateway(ctx context.Context, db DBTX, args []UpsertGatewayParams) error { diff --git a/go/pkg/partition/db/gateway_upsert.sql_generated.go b/go/pkg/partition/db/gateway_upsert.sql_generated.go index a7dbaff3ec..ffde6afdfe 100644 --- a/go/pkg/partition/db/gateway_upsert.sql_generated.go +++ b/go/pkg/partition/db/gateway_upsert.sql_generated.go @@ -11,7 +11,10 @@ import ( const upsertGateway = `-- name: UpsertGateway :exec INSERT INTO gateways (workspace_id, hostname, config) -VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE config = VALUES(config) +VALUES (?, ?, ?) +ON DUPLICATE KEY UPDATE + config = VALUES(config), + workspace_id = VALUES(workspace_id) ` type UpsertGatewayParams struct { @@ -23,7 +26,10 @@ type UpsertGatewayParams struct { // UpsertGateway // // INSERT INTO gateways (workspace_id, hostname, config) -// VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE config = VALUES(config) +// VALUES (?, ?, ?) +// ON DUPLICATE KEY UPDATE +// config = VALUES(config), +// workspace_id = VALUES(workspace_id) func (q *Queries) UpsertGateway(ctx context.Context, db DBTX, arg UpsertGatewayParams) error { _, err := db.ExecContext(ctx, upsertGateway, arg.WorkspaceID, arg.Hostname, arg.Config) return err diff --git a/go/pkg/partition/db/querier_bulk_generated.go b/go/pkg/partition/db/querier_bulk_generated.go index 877a6f4207..c636bea021 100644 --- a/go/pkg/partition/db/querier_bulk_generated.go +++ b/go/pkg/partition/db/querier_bulk_generated.go @@ -11,5 +11,5 @@ type BulkQuerier interface { UpsertVM(ctx context.Context, db DBTX, args []UpsertVMParams) error } -// Ensure Queries implements BulkQuerier +// Ensure BulkQueries implements BulkQuerier var _ BulkQuerier = (*BulkQueries)(nil) diff --git a/go/pkg/partition/db/querier_generated.go b/go/pkg/partition/db/querier_generated.go index c0a9cd5762..1e06a2b56f 100644 --- a/go/pkg/partition/db/querier_generated.go +++ b/go/pkg/partition/db/querier_generated.go @@ -40,7 +40,10 @@ type Querier interface { //UpsertGateway // // INSERT INTO gateways (workspace_id, hostname, config) - // VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE config = VALUES(config) + // VALUES (?, ?, ?) + // ON DUPLICATE KEY UPDATE + // config = VALUES(config), + // workspace_id = VALUES(workspace_id) UpsertGateway(ctx context.Context, db DBTX, arg UpsertGatewayParams) error //UpsertVM // diff --git a/go/pkg/partition/db/queries/gateway_upsert.sql b/go/pkg/partition/db/queries/gateway_upsert.sql index 329e620016..1b7c4f0352 100644 --- a/go/pkg/partition/db/queries/gateway_upsert.sql +++ b/go/pkg/partition/db/queries/gateway_upsert.sql @@ -1,3 +1,6 @@ -- name: UpsertGateway :exec INSERT INTO gateways (workspace_id, hostname, config) -VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE config = VALUES(config); +VALUES (?, ?, ?) +ON DUPLICATE KEY UPDATE + config = VALUES(config), + workspace_id = VALUES(workspace_id); From 2c1c2f30ad0d85384f537e7bc6c46b802cfa70be Mon Sep 17 00:00:00 2001 From: Flo Date: Thu, 11 Sep 2025 11:56:16 +0200 Subject: [PATCH 09/20] add default domain in ctrl plane --- go/apps/ctrl/config.go | 3 +++ go/apps/ctrl/run.go | 1 + go/apps/ctrl/services/deployment/deploy_workflow.go | 8 ++++++-- go/cmd/ctrl/main.go | 2 ++ go/cmd/deploy/build_docker.go | 3 +++ go/cmd/deploy/main.go | 3 +++ 6 files changed, 18 insertions(+), 2 deletions(-) diff --git a/go/apps/ctrl/config.go b/go/apps/ctrl/config.go index e874590a5f..9187076ed4 100644 --- a/go/apps/ctrl/config.go +++ b/go/apps/ctrl/config.go @@ -65,6 +65,9 @@ type Config struct { VaultS3 S3Config AcmeEnabled bool + + // DefaultDomain is the domain used for auto-generated hostnames (e.g., "unkey.app" or "unkey.cloud") + DefaultDomain string } func (c Config) Validate() error { diff --git a/go/apps/ctrl/run.go b/go/apps/ctrl/run.go index bff312cb34..7533ddcaaa 100644 --- a/go/apps/ctrl/run.go +++ b/go/apps/ctrl/run.go @@ -192,6 +192,7 @@ func Run(ctx context.Context, cfg Config) error { PartitionDB: partitionDB, MetaldBackend: cfg.MetaldBackend, MetalD: metaldClient, + DefaultDomain: cfg.DefaultDomain, }) err = hydra.RegisterWorkflow(hydraWorker, deployWorkflow) if err != nil { diff --git a/go/apps/ctrl/services/deployment/deploy_workflow.go b/go/apps/ctrl/services/deployment/deploy_workflow.go index 89530c6a9f..c6ad0846b1 100644 --- a/go/apps/ctrl/services/deployment/deploy_workflow.go +++ b/go/apps/ctrl/services/deployment/deploy_workflow.go @@ -25,6 +25,7 @@ type DeployWorkflow struct { partitionDB db.Database logger logging.Logger deploymentBackend DeploymentBackend + defaultDomain string } type DeployWorkflowConfig struct { @@ -33,6 +34,7 @@ type DeployWorkflowConfig struct { PartitionDB db.Database MetalD metaldv1connect.VmServiceClient MetaldBackend string + DefaultDomain string } // NewDeployWorkflow creates a new deploy workflow instance @@ -51,6 +53,7 @@ func NewDeployWorkflow(cfg DeployWorkflowConfig) *DeployWorkflow { partitionDB: cfg.PartitionDB, logger: cfg.Logger, deploymentBackend: deploymentBackend, + defaultDomain: cfg.DefaultDomain, } } @@ -67,6 +70,7 @@ type DeployRequest struct { DeploymentID string `json:"deployment_id"` DockerImage string `json:"docker_image"` Hostname string `json:"hostname"` + Domain string `json:"domain"` } // DeploymentResult holds the deployment outcome @@ -279,10 +283,10 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro } } - // Generate primary hostname: branch-identifier-workspace.unkey.app + // Generate primary hostname: branch-identifier-workspace.domain cleanIdentifier := strings.ReplaceAll(identifier, "_", "-") cleanBranch := strings.ReplaceAll(branch, "/", "-") - autoGeneratedHostname := fmt.Sprintf("%s-%s-%s.unkey.app", cleanBranch, cleanIdentifier, req.WorkspaceID) + autoGeneratedHostname := fmt.Sprintf("%s-%s-%s.%s", cleanBranch, cleanIdentifier, req.WorkspaceID, w.defaultDomain) domains = append(domains, autoGeneratedHostname) w.logger.Info("generated all domains", diff --git a/go/cmd/ctrl/main.go b/go/cmd/ctrl/main.go index 5978b6163d..bdb5e741ce 100644 --- a/go/cmd/ctrl/main.go +++ b/go/cmd/ctrl/main.go @@ -73,6 +73,7 @@ var Cmd = &cli.Command{ cli.Bool("acme-enabled", "Enable Let's Encrypt for acme challenges", cli.EnvVar("UNKEY_ACME_ENABLED")), cli.String("metald-backend", "Whether to call metalD or go to a fallback (docker or k8s)", cli.EnvVar("UNKEY_METALD_BACKEND")), + cli.String("default-domain", "Default domain for auto-generated hostnames", cli.Default("unkey.app"), cli.EnvVar("UNKEY_DEFAULT_DOMAIN")), }, Action: action, } @@ -131,6 +132,7 @@ func action(ctx context.Context, cmd *cli.Command) error { AcmeEnabled: cmd.Bool("acme-enabled"), MetaldBackend: cmd.String("metald-backend"), + DefaultDomain: cmd.String("default-domain"), // Common Clock: clock.New(), diff --git a/go/cmd/deploy/build_docker.go b/go/cmd/deploy/build_docker.go index 670fa0fc1f..a794d2d3ff 100644 --- a/go/cmd/deploy/build_docker.go +++ b/go/cmd/deploy/build_docker.go @@ -149,6 +149,9 @@ func buildImage(ctx context.Context, opts DeployOptions, dockerImage string, ui if opts.Dockerfile != DefaultDockerfile { buildArgs = append(buildArgs, "-f", opts.Dockerfile) } + if opts.Linux { + buildArgs = append(buildArgs, "--platform", "linux/amd64") + } buildArgs = append(buildArgs, "-t", dockerImage, "--build-arg", fmt.Sprintf("%s=%s", VersionBuildArg, opts.Commit), diff --git a/go/cmd/deploy/main.go b/go/cmd/deploy/main.go index 5ec27defe3..3387717535 100644 --- a/go/cmd/deploy/main.go +++ b/go/cmd/deploy/main.go @@ -95,6 +95,7 @@ type DeployOptions struct { ControlPlaneURL string AuthToken string Hostname string + Linux bool } var DeployFlags = []cli.Flag{ @@ -118,6 +119,7 @@ var DeployFlags = []cli.Flag{ cli.EnvVar(EnvRegistry)), cli.Bool("skip-push", "Skip pushing to registry (for local testing)"), cli.Bool("verbose", "Show detailed output for build and deployment operations"), + cli.Bool("linux", "Build Docker image for linux/amd64 platform (for deployment to cloud clusters)"), // Control plane flags (internal) cli.String("control-plane-url", "Control plane URL", cli.Default(DefaultControlPlaneURL)), cli.String("auth-token", "Control plane auth token", cli.Default(DefaultAuthToken)), @@ -204,6 +206,7 @@ func DeployAction(ctx context.Context, cmd *cli.Command) error { ControlPlaneURL: cmd.String("control-plane-url"), AuthToken: cmd.String("auth-token"), Hostname: cmd.String("hostname"), + Linux: cmd.Bool("linux"), } return executeDeploy(ctx, opts) From aebc1526b57d289f5c14febf43572c77d12efecb Mon Sep 17 00:00:00 2001 From: Flo Date: Thu, 11 Sep 2025 12:18:58 +0200 Subject: [PATCH 10/20] add lowercasing for domain --- go/apps/ctrl/services/deployment/deploy_workflow.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/go/apps/ctrl/services/deployment/deploy_workflow.go b/go/apps/ctrl/services/deployment/deploy_workflow.go index c6ad0846b1..02883ed021 100644 --- a/go/apps/ctrl/services/deployment/deploy_workflow.go +++ b/go/apps/ctrl/services/deployment/deploy_workflow.go @@ -284,9 +284,10 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro } // Generate primary hostname: branch-identifier-workspace.domain - cleanIdentifier := strings.ReplaceAll(identifier, "_", "-") - cleanBranch := strings.ReplaceAll(branch, "/", "-") - autoGeneratedHostname := fmt.Sprintf("%s-%s-%s.%s", cleanBranch, cleanIdentifier, req.WorkspaceID, w.defaultDomain) + cleanIdentifier := strings.ToLower(strings.ReplaceAll(identifier, "_", "-")) + cleanBranch := strings.ToLower(strings.ReplaceAll(branch, "/", "-")) + cleanWorkspaceID := strings.ToLower(req.WorkspaceID) + autoGeneratedHostname := fmt.Sprintf("%s-%s-%s.%s", cleanBranch, cleanIdentifier, cleanWorkspaceID, w.defaultDomain) domains = append(domains, autoGeneratedHostname) w.logger.Info("generated all domains", From b15dbd1b39e5c58097ad844c9d728ae09be50a18 Mon Sep 17 00:00:00 2001 From: Flo Date: Thu, 11 Sep 2025 13:42:18 +0200 Subject: [PATCH 11/20] feat(ctrl): cloudflare acme dns provider --- go/apps/ctrl/config.go | 28 ++++- go/apps/ctrl/run.go | 89 ++++++++++++- .../acme/providers/cloudflare_provider.go | 119 ++++++++++++++++++ .../services/acme/providers/http_provider.go | 18 +-- go/cmd/ctrl/main.go | 17 ++- ...challenge_list_executable.sql_generated.go | 39 ++++-- go/pkg/db/querier_generated.go | 7 +- .../acme_challenge_list_executable.sql | 5 +- 8 files changed, 290 insertions(+), 32 deletions(-) create mode 100644 go/apps/ctrl/services/acme/providers/cloudflare_provider.go diff --git a/go/apps/ctrl/config.go b/go/apps/ctrl/config.go index 9187076ed4..0ac209a5f9 100644 --- a/go/apps/ctrl/config.go +++ b/go/apps/ctrl/config.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/unkeyed/unkey/go/apps/ctrl/services/deployment/backends" + "github.com/unkeyed/unkey/go/pkg/assert" "github.com/unkeyed/unkey/go/pkg/clock" "github.com/unkeyed/unkey/go/pkg/tls" ) @@ -15,6 +16,22 @@ type S3Config struct { AccessKeySecret string } +type CloudflareConfig struct { + // Enables DNS-01 challenges using Cloudflare + Enabled bool + + // ApiToken is the Cloudflare API token with Zone:Read, DNS:Edit permissions + ApiToken string +} + +type AcmeConfig struct { + // Enables ACME challenges for TLS certificates + Enabled bool + + // Enables DNS-01 challenges using Cloudflare + Cloudflare CloudflareConfig +} + type Config struct { // InstanceID is the unique identifier for this instance of the control plane server InstanceID string @@ -64,9 +81,9 @@ type Config struct { VaultMasterKeys []string VaultS3 S3Config - AcmeEnabled bool + // --- ACME/Cloudflare Configuration --- + Acme AcmeConfig - // DefaultDomain is the domain used for auto-generated hostnames (e.g., "unkey.app" or "unkey.cloud") DefaultDomain string } @@ -76,5 +93,12 @@ func (c Config) Validate() error { return fmt.Errorf("invalid metald backend configuration: %w", err) } + // Validate Cloudflare configuration if enabled + if c.Acme.Enabled && c.Acme.Cloudflare.Enabled { + if err := assert.NotEmpty(c.Acme.Cloudflare.ApiToken, "cloudflare API token is required when cloudflare is enabled"); err != nil { + return err + } + } + return nil } diff --git a/go/apps/ctrl/run.go b/go/apps/ctrl/run.go index 7533ddcaaa..cdf776f5e4 100644 --- a/go/apps/ctrl/run.go +++ b/go/apps/ctrl/run.go @@ -2,6 +2,7 @@ package ctrl import ( "context" + "database/sql" "fmt" "log/slog" "net/http" @@ -21,6 +22,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/otel" "github.com/unkeyed/unkey/go/pkg/otel/logging" "github.com/unkeyed/unkey/go/pkg/shutdown" + "github.com/unkeyed/unkey/go/pkg/uid" "github.com/unkeyed/unkey/go/pkg/vault" "github.com/unkeyed/unkey/go/pkg/vault/storage" pkgversion "github.com/unkeyed/unkey/go/pkg/version" @@ -264,7 +266,7 @@ func Run(ctx context.Context, cfg Config) error { } }() - if cfg.AcmeEnabled { + if cfg.Acme.Enabled { acmeClient, err := acme.GetOrCreateUser(ctx, acme.UserConfig{ DB: database, Logger: logger, @@ -286,6 +288,74 @@ func Run(ctx context.Context, cfg Config) error { return fmt.Errorf("failed to set HTTP-01 provider: %w", err) } + // Set up Cloudflare DNS-01 challenge provider if enabled + if cfg.Acme.Cloudflare.Enabled { + cloudflareProvider, err := providers.NewCloudflareProvider(providers.CloudflareProviderConfig{ + DB: database, + Logger: logger, + APIToken: cfg.Acme.Cloudflare.ApiToken, + DefaultDomain: cfg.DefaultDomain, + }) + if err != nil { + logger.Error("failed to create Cloudflare DNS provider", "error", err) + return fmt.Errorf("failed to create Cloudflare DNS provider: %w", err) + } + + err = acmeClient.Challenge.SetDNS01Provider(cloudflareProvider) + if err != nil { + logger.Error("failed to set DNS-01 provider", "error", err) + return fmt.Errorf("failed to set DNS-01 provider: %w", err) + } + + logger.Info("Cloudflare DNS-01 challenge provider configured") + + if cfg.DefaultDomain != "" { + wildcardDomain := "*." + cfg.DefaultDomain + + // Check if we already have a challenge or certificate for the wildcard domain + _, err := db.Query.FindDomainByDomain(ctx, database.RO(), wildcardDomain) + if err != nil && !db.IsNotFound(err) { + logger.Error("Failed to check existing wildcard domain", "error", err, "domain", wildcardDomain) + } else if db.IsNotFound(err) { + now := time.Now().UnixMilli() + domainID := uid.New("domain") + + // Insert domain record + err = db.Query.InsertDomain(ctx, database.RW(), db.InsertDomainParams{ + ID: domainID, + WorkspaceID: "unkey", // Default workspace for wildcard cert + Domain: wildcardDomain, + CreatedAt: now, + UpdatedAt: sql.NullInt64{Valid: true, Int64: now}, + Type: db.DomainsTypeCustom, + }) + if err != nil { + logger.Error("Failed to create wildcard domain", "error", err, "domain", wildcardDomain) + } else { + // Insert challenge record + expiresAt := time.Now().Add(90 * 24 * time.Hour).UnixMilli() // 90 days + + err = db.Query.InsertAcmeChallenge(ctx, database.RW(), db.InsertAcmeChallengeParams{ + WorkspaceID: "unkey", + DomainID: domainID, + Token: "", + Authorization: "", + Status: db.AcmeChallengesStatusWaiting, + Type: db.AcmeChallengesTypeDNS01, // Use DNS-01 for wildcard + CreatedAt: now, + UpdatedAt: sql.NullInt64{Valid: true, Int64: now}, + ExpiresAt: expiresAt, + }) + if err != nil { + logger.Error("Failed to create wildcard challenge", "error", err, "domain", wildcardDomain) + } else { + logger.Info("Created wildcard domain and challenge", "domain", wildcardDomain) + } + } + } + } + } + // Register deployment workflow with Hydra worker acmeWorkflows := acme.NewCertificateChallenge(acme.CertificateChallengeConfig{ DB: database, @@ -303,16 +373,26 @@ func Run(ctx context.Context, cfg Config) error { go func() { logger.Info("Starting cert worker") + // HTTP-01 challenges are always available (we always set up HTTP provider) + supportedTypes := []db.AcmeChallengesType{db.AcmeChallengesTypeHTTP01} + + // DNS-01 challenges require Cloudflare to be enabled + if cfg.Acme.Cloudflare.Enabled { + supportedTypes = append(supportedTypes, db.AcmeChallengesTypeDNS01) + } + registerErr := hydraEngine.RegisterCron("*/5 * * * *", "start-certificate-challenges", func(ctx context.Context, payload hydra.CronPayload) error { - challenges, err := db.Query.ListExecutableChallenges(ctx, database.RO()) + executableChallenges, err := db.Query.ListExecutableChallenges(ctx, database.RO(), supportedTypes) if err != nil { logger.Error("Failed to start workflow", "error", err) return err } - logger.Info("Starting certificate challenges", "count", len(challenges)) + logger.Info("Starting certificate challenges", + "executable_challenges", len(executableChallenges), + "supported_types", supportedTypes) - for _, challenge := range challenges { + for _, challenge := range executableChallenges { executionID, err := hydraEngine.StartWorkflow(ctx, "certificate_challenge", acme.CertificateChallengeRequest{ ID: challenge.ID, @@ -340,6 +420,7 @@ func Run(ctx context.Context, cfg Config) error { } }() } + // Start Hydra worker go func() { logger.Info("Starting Hydra workflow worker") diff --git a/go/apps/ctrl/services/acme/providers/cloudflare_provider.go b/go/apps/ctrl/services/acme/providers/cloudflare_provider.go new file mode 100644 index 0000000000..53b2dfccd1 --- /dev/null +++ b/go/apps/ctrl/services/acme/providers/cloudflare_provider.go @@ -0,0 +1,119 @@ +package providers + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/providers/dns/cloudflare" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/otel/logging" +) + +var _ challenge.Provider = (*CloudflareProvider)(nil) + +// CloudflareProvider implements the lego challenge.Provider interface for DNS-01 challenges +// It uses Cloudflare DNS to store challenges and tracks them in the database +type CloudflareProvider struct { + db db.Database + logger logging.Logger + provider *cloudflare.DNSProvider + defaultDomain string +} + +type CloudflareProviderConfig struct { + DB db.Database + Logger logging.Logger + APIToken string // Cloudflare API token with Zone:Read, DNS:Edit permissions + DefaultDomain string // Default domain for wildcard certificate handling +} + +// NewCloudflareProvider creates a new DNS-01 challenge provider using Cloudflare +func NewCloudflareProvider(cfg CloudflareProviderConfig) (*CloudflareProvider, error) { + config := cloudflare.NewDefaultConfig() + config.AuthToken = cfg.APIToken + config.TTL = 120 // 2 minutes TTL for challenge records + + provider, err := cloudflare.NewDNSProviderConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create Cloudflare DNS provider: %w", err) + } + + return &CloudflareProvider{ + db: cfg.DB, + logger: cfg.Logger, + provider: provider, + defaultDomain: cfg.DefaultDomain, + }, nil +} + +// Present creates a DNS TXT record for the ACME challenge +func (p *CloudflareProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + // Find domain in database to track the challenge + // For DNS-01 challenges on the default domain, Let's Encrypt passes the base domain + // but we store the wildcard domain in the database + if domain == p.defaultDomain { + // This is our default domain - look for the wildcard version + domain = "*." + domain + } + + dom, err := db.Query.FindDomainByDomain(ctx, p.db.RO(), domain) + if err != nil { + p.logger.Error("failed to find domain", "error", err, "domain", domain) + return fmt.Errorf("failed to find domain: %w", err) + } + + p.logger.Info("presenting DNS challenge", "domain", domain, "token", token) + + // Create the DNS challenge record using Cloudflare + err = p.provider.Present(domain, token, keyAuth) + if err != nil { + p.logger.Error("failed to present DNS challenge", "error", err, "domain", domain, "token", token) + return fmt.Errorf("failed to present DNS challenge: %w", err) + } + + // Update the database to track the challenge + err = db.Query.UpdateAcmeChallengePending(ctx, p.db.RW(), db.UpdateAcmeChallengePendingParams{ + DomainID: dom.ID, + Status: db.AcmeChallengesStatusPending, + Token: token, + Authorization: keyAuth, + UpdatedAt: sql.NullInt64{Int64: time.Now().UnixMilli(), Valid: true}, + }) + + if err != nil { + p.logger.Error("failed to store challenge in database", "error", err, "domain", domain, "token", token) + // Don't cleanup DNS record - Let's Encrypt still needs it for validation + // The DNS record will be cleaned up later in CleanUp() regardless of success/failure + return fmt.Errorf("failed to store challenge: %w", err) + } + + p.logger.Info("DNS challenge presented successfully", "domain", domain, "token", token) + + // Give DNS time to propagate before Let's Encrypt validates + time.Sleep(30 * time.Second) + + return nil +} + +// CleanUp removes the DNS TXT record and updates the database +func (p *CloudflareProvider) CleanUp(domain, token, keyAuth string) error { + p.logger.Info("cleaning up DNS challenge", "domain", domain, "token", token) + + // Clean up the DNS record first + err := p.provider.CleanUp(domain, token, keyAuth) + if err != nil { + p.logger.Warn("failed to clean up DNS challenge record", "error", err, "domain", domain, "token", token) + } + + return nil +} + +// Timeout returns the timeout and polling interval for the DNS challenge +func (p *CloudflareProvider) Timeout() (timeout, interval time.Duration) { + return p.provider.Timeout() +} diff --git a/go/apps/ctrl/services/acme/providers/http_provider.go b/go/apps/ctrl/services/acme/providers/http_provider.go index c501df9fb7..64e5e1a031 100644 --- a/go/apps/ctrl/services/acme/providers/http_provider.go +++ b/go/apps/ctrl/services/acme/providers/http_provider.go @@ -64,25 +64,27 @@ func (p *HTTPProvider) Present(domain, token, keyAuth string) error { return nil } -// CleanUp removes the challenge from the database after validation +// CleanUp removes the challenge token from the database after validation func (p *HTTPProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() dom, err := db.Query.FindDomainByDomain(ctx, p.db.RO(), domain) if err != nil { - p.logger.Error("failed to find domain", "error", err, "domain", domain) + p.logger.Error("failed to find domain during cleanup", "error", err, "domain", domain) return fmt.Errorf("failed to find domain: %w", err) } - // Update the challenge status to mark it as verified - err = db.Query.UpdateAcmeChallengeStatus(ctx, p.db.RW(), db.UpdateAcmeChallengeStatusParams{ - DomainID: dom.ID, - Status: db.AcmeChallengesStatusVerified, - UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()}, + // Clear the token and authorization so the gateway stops serving the challenge + err = db.Query.UpdateAcmeChallengePending(ctx, p.db.RW(), db.UpdateAcmeChallengePendingParams{ + DomainID: dom.ID, + Status: db.AcmeChallengesStatusPending, // Keep existing status, just clear token + Token: "", // Clear token + Authorization: "", // Clear authorization + UpdatedAt: sql.NullInt64{Int64: time.Now().UnixMilli(), Valid: true}, }) if err != nil { - p.logger.Warn("failed to clean up challenge", "error", err, "domain", domain, "token", token) + p.logger.Warn("failed to clean up challenge token", "error", err, "domain", domain, "token", token) } return nil diff --git a/go/cmd/ctrl/main.go b/go/cmd/ctrl/main.go index bdb5e741ce..b0f947517a 100644 --- a/go/cmd/ctrl/main.go +++ b/go/cmd/ctrl/main.go @@ -56,6 +56,7 @@ var Cmd = &cli.Command{ cli.EnvVar("UNKEY_AUTH_TOKEN")), cli.String("metald-address", "Full URL of the metald service for VM operations. Required for deployments. Example: https://metald.example.com:8080", cli.Required(), cli.EnvVar("UNKEY_METALD_ADDRESS")), + cli.String("metald-backend", "Whether to call metalD or go to a fallback (docker or k8s)", cli.EnvVar("UNKEY_METALD_BACKEND")), cli.String("spiffe-socket-path", "Path to SPIFFE agent socket for mTLS authentication. Default: /var/lib/spire/agent/agent.sock", cli.Default("/var/lib/spire/agent/agent.sock"), cli.EnvVar("UNKEY_SPIFFE_SOCKET_PATH")), @@ -72,7 +73,9 @@ var Cmd = &cli.Command{ cli.Required(), cli.EnvVar("UNKEY_VAULT_S3_ACCESS_KEY_SECRET")), cli.Bool("acme-enabled", "Enable Let's Encrypt for acme challenges", cli.EnvVar("UNKEY_ACME_ENABLED")), - cli.String("metald-backend", "Whether to call metalD or go to a fallback (docker or k8s)", cli.EnvVar("UNKEY_METALD_BACKEND")), + cli.Bool("acme-cloudflare-enabled", "Enable Cloudflare for wildcard certificates", cli.EnvVar("UNKEY_ACME_CLOUDFLARE_ENABLED")), + cli.String("acme-cloudflare-api-token", "Cloudflare API token for Let's Encrypt", cli.EnvVar("UNKEY_ACME_CLOUDFLARE_API_TOKEN")), + cli.String("default-domain", "Default domain for auto-generated hostnames", cli.Default("unkey.app"), cli.EnvVar("UNKEY_DEFAULT_DOMAIN")), }, Action: action, @@ -119,6 +122,7 @@ func action(ctx context.Context, cmd *cli.Command) error { // Control Plane Specific AuthToken: cmd.String("auth-token"), MetaldAddress: cmd.String("metald-address"), + MetaldBackend: cmd.String("metald-backend"), SPIFFESocketPath: cmd.String("spiffe-socket-path"), // Vault configuration @@ -130,8 +134,15 @@ func action(ctx context.Context, cmd *cli.Command) error { AccessKeyID: cmd.String("vault-s3-access-key-id"), }, - AcmeEnabled: cmd.Bool("acme-enabled"), - MetaldBackend: cmd.String("metald-backend"), + // Acme configuration + Acme: ctrl.AcmeConfig{ + Enabled: cmd.Bool("acme-enabled"), + Cloudflare: ctrl.CloudflareConfig{ + Enabled: cmd.Bool("acme-cloudflare-enabled"), + ApiToken: cmd.String("acme-cloudflare-api-token"), + }, + }, + DefaultDomain: cmd.String("default-domain"), // Common diff --git a/go/pkg/db/acme_challenge_list_executable.sql_generated.go b/go/pkg/db/acme_challenge_list_executable.sql_generated.go index 3d3a45b36b..6f69d88e2e 100644 --- a/go/pkg/db/acme_challenge_list_executable.sql_generated.go +++ b/go/pkg/db/acme_challenge_list_executable.sql_generated.go @@ -7,29 +7,43 @@ package db import ( "context" + "strings" ) const listExecutableChallenges = `-- name: ListExecutableChallenges :many -SELECT dc.id, dc.workspace_id, d.domain FROM acme_challenges dc +SELECT dc.id, dc.workspace_id, dc.type, d.domain FROM acme_challenges dc JOIN domains d ON dc.domain_id = d.id -WHERE dc.status = 'waiting' OR (dc.status = 'verified' AND dc.expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY)) +WHERE (dc.status = 'waiting' OR (dc.status = 'verified' AND dc.expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY))) +AND dc.type IN (/*SLICE:verification_types*/?) ORDER BY d.created_at ASC ` type ListExecutableChallengesRow struct { - ID uint64 `db:"id"` - WorkspaceID string `db:"workspace_id"` - Domain string `db:"domain"` + ID uint64 `db:"id"` + WorkspaceID string `db:"workspace_id"` + Type AcmeChallengesType `db:"type"` + Domain string `db:"domain"` } // ListExecutableChallenges // -// SELECT dc.id, dc.workspace_id, d.domain FROM acme_challenges dc +// SELECT dc.id, dc.workspace_id, dc.type, d.domain FROM acme_challenges dc // JOIN domains d ON dc.domain_id = d.id -// WHERE dc.status = 'waiting' OR (dc.status = 'verified' AND dc.expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY)) +// WHERE (dc.status = 'waiting' OR (dc.status = 'verified' AND dc.expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY))) +// AND dc.type IN (/*SLICE:verification_types*/?) // ORDER BY d.created_at ASC -func (q *Queries) ListExecutableChallenges(ctx context.Context, db DBTX) ([]ListExecutableChallengesRow, error) { - rows, err := db.QueryContext(ctx, listExecutableChallenges) +func (q *Queries) ListExecutableChallenges(ctx context.Context, db DBTX, verificationTypes []AcmeChallengesType) ([]ListExecutableChallengesRow, error) { + query := listExecutableChallenges + var queryParams []interface{} + if len(verificationTypes) > 0 { + for _, v := range verificationTypes { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:verification_types*/?", strings.Repeat(",?", len(verificationTypes))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:verification_types*/?", "NULL", 1) + } + rows, err := db.QueryContext(ctx, query, queryParams...) if err != nil { return nil, err } @@ -37,7 +51,12 @@ func (q *Queries) ListExecutableChallenges(ctx context.Context, db DBTX) ([]List var items []ListExecutableChallengesRow for rows.Next() { var i ListExecutableChallengesRow - if err := rows.Scan(&i.ID, &i.WorkspaceID, &i.Domain); err != nil { + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Type, + &i.Domain, + ); err != nil { return nil, err } items = append(items, i) diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index 8abfe5fccb..1838037244 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -1218,11 +1218,12 @@ type Querier interface { ListDirectPermissionsByKeyID(ctx context.Context, db DBTX, keyID string) ([]Permission, error) //ListExecutableChallenges // - // SELECT dc.id, dc.workspace_id, d.domain FROM acme_challenges dc + // SELECT dc.id, dc.workspace_id, dc.type, d.domain FROM acme_challenges dc // JOIN domains d ON dc.domain_id = d.id - // WHERE dc.status = 'waiting' OR (dc.status = 'verified' AND dc.expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY)) + // WHERE (dc.status = 'waiting' OR (dc.status = 'verified' AND dc.expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY))) + // AND dc.type IN (/*SLICE:verification_types*/?) // ORDER BY d.created_at ASC - ListExecutableChallenges(ctx context.Context, db DBTX) ([]ListExecutableChallengesRow, error) + ListExecutableChallenges(ctx context.Context, db DBTX, verificationTypes []AcmeChallengesType) ([]ListExecutableChallengesRow, error) //ListIdentities // // SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at diff --git a/go/pkg/db/queries/acme_challenge_list_executable.sql b/go/pkg/db/queries/acme_challenge_list_executable.sql index 07e8342c9a..5e888a87ba 100644 --- a/go/pkg/db/queries/acme_challenge_list_executable.sql +++ b/go/pkg/db/queries/acme_challenge_list_executable.sql @@ -1,5 +1,6 @@ -- name: ListExecutableChallenges :many -SELECT dc.id, dc.workspace_id, d.domain FROM acme_challenges dc +SELECT dc.id, dc.workspace_id, dc.type, d.domain FROM acme_challenges dc JOIN domains d ON dc.domain_id = d.id -WHERE dc.status = 'waiting' OR (dc.status = 'verified' AND dc.expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY)) +WHERE (dc.status = 'waiting' OR (dc.status = 'verified' AND dc.expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY))) +AND dc.type IN (sqlc.slice(verification_types)) ORDER BY d.created_at ASC; From f25c2df94a81e3effdc4f1aa37cde43ccb87be0e Mon Sep 17 00:00:00 2001 From: Flo Date: Thu, 11 Sep 2025 14:13:17 +0200 Subject: [PATCH 12/20] fix: wrong domain for challenge --- .../ctrl/services/acme/providers/cloudflare_provider.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/go/apps/ctrl/services/acme/providers/cloudflare_provider.go b/go/apps/ctrl/services/acme/providers/cloudflare_provider.go index 53b2dfccd1..698f5fc8fe 100644 --- a/go/apps/ctrl/services/acme/providers/cloudflare_provider.go +++ b/go/apps/ctrl/services/acme/providers/cloudflare_provider.go @@ -56,14 +56,15 @@ func (p *CloudflareProvider) Present(domain, token, keyAuth string) error { // Find domain in database to track the challenge // For DNS-01 challenges on the default domain, Let's Encrypt passes the base domain // but we store the wildcard domain in the database + searchDomain := domain if domain == p.defaultDomain { // This is our default domain - look for the wildcard version - domain = "*." + domain + searchDomain = "*." + domain } - dom, err := db.Query.FindDomainByDomain(ctx, p.db.RO(), domain) + dom, err := db.Query.FindDomainByDomain(ctx, p.db.RO(), searchDomain) if err != nil { - p.logger.Error("failed to find domain", "error", err, "domain", domain) + p.logger.Error("failed to find domain", "error", err, "domain", searchDomain) return fmt.Errorf("failed to find domain: %w", err) } From 1750e1b331617622781c37304b58e4fd31f6f763 Mon Sep 17 00:00:00 2001 From: Flo Date: Thu, 11 Sep 2025 14:15:29 +0200 Subject: [PATCH 13/20] fix: more timeout --- go/apps/ctrl/services/acme/providers/cloudflare_provider.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go/apps/ctrl/services/acme/providers/cloudflare_provider.go b/go/apps/ctrl/services/acme/providers/cloudflare_provider.go index 698f5fc8fe..27a9c20d61 100644 --- a/go/apps/ctrl/services/acme/providers/cloudflare_provider.go +++ b/go/apps/ctrl/services/acme/providers/cloudflare_provider.go @@ -33,9 +33,9 @@ type CloudflareProviderConfig struct { // NewCloudflareProvider creates a new DNS-01 challenge provider using Cloudflare func NewCloudflareProvider(cfg CloudflareProviderConfig) (*CloudflareProvider, error) { config := cloudflare.NewDefaultConfig() + config.PropagationTimeout = time.Minute * 5 // 5 minutes propagation timeout config.AuthToken = cfg.APIToken - config.TTL = 120 // 2 minutes TTL for challenge records - + config.TTL = 60 * 10 // 10 minutes TTL for challenge records provider, err := cloudflare.NewDNSProviderConfig(config) if err != nil { return nil, fmt.Errorf("failed to create Cloudflare DNS provider: %w", err) From c357fc7ed5844db313b140185bb40c35424cbd7d Mon Sep 17 00:00:00 2001 From: Flo Date: Thu, 11 Sep 2025 16:32:15 +0200 Subject: [PATCH 14/20] fix: make challenge unique per domain --- go/apps/ctrl/run.go | 1 - .../services/acme/certificate_workflow.go | 26 ++++++------- .../acme/providers/cloudflare_provider.go | 16 ++++---- .../services/acme/providers/http_provider.go | 26 ++++++++----- .../services/deployment/deploy_workflow.go | 26 +++++-------- ...me_challenge_clear_tokens.sql_generated.go | 39 +++++++++++++++++++ ...date_verified_with_expiry.sql_generated.go | 39 +++++++++++++++++++ go/pkg/db/querier_generated.go | 12 ++++++ .../queries/acme_challenge_clear_tokens.sql | 4 ++ ..._challenge_update_verified_with_expiry.sql | 4 ++ go/pkg/db/schema.sql | 3 +- internal/db/src/schema/acme_challenges.ts | 6 ++- 12 files changed, 150 insertions(+), 52 deletions(-) create mode 100644 go/pkg/db/acme_challenge_clear_tokens.sql_generated.go create mode 100644 go/pkg/db/acme_challenge_update_verified_with_expiry.sql_generated.go create mode 100644 go/pkg/db/queries/acme_challenge_clear_tokens.sql create mode 100644 go/pkg/db/queries/acme_challenge_update_verified_with_expiry.sql diff --git a/go/apps/ctrl/run.go b/go/apps/ctrl/run.go index cdf776f5e4..e711f62c20 100644 --- a/go/apps/ctrl/run.go +++ b/go/apps/ctrl/run.go @@ -395,7 +395,6 @@ func Run(ctx context.Context, cfg Config) error { for _, challenge := range executableChallenges { executionID, err := hydraEngine.StartWorkflow(ctx, "certificate_challenge", acme.CertificateChallengeRequest{ - ID: challenge.ID, WorkspaceID: challenge.WorkspaceID, Domain: challenge.Domain, }, diff --git a/go/apps/ctrl/services/acme/certificate_workflow.go b/go/apps/ctrl/services/acme/certificate_workflow.go index e19d8ad8cd..2e5c5ad6da 100644 --- a/go/apps/ctrl/services/acme/certificate_workflow.go +++ b/go/apps/ctrl/services/acme/certificate_workflow.go @@ -51,7 +51,6 @@ func (w *CertificateChallenge) Name() string { // CertificateChallengeRequest defines the input for the certificate challenge workflow type CertificateChallengeRequest struct { - ID uint64 `json:"id"` WorkspaceID string `json:"workspace_id"` Domain string `json:"domain"` } @@ -64,9 +63,9 @@ type EncryptedCertificate struct { // Run executes the complete build and deployment workflow func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateChallengeRequest) error { - w.logger.Info("starting lets-encrypt challenge", "workspace_id", req.WorkspaceID, "domain", req.Domain) + w.logger.Info("starting certificate challenge", "workspace_id", req.WorkspaceID, "domain", req.Domain) - dom, err := hydra.Step(ctx, "find-domain", func(stepCtx context.Context) (db.Domain, error) { + dom, err := hydra.Step(ctx, "resolve-domain", func(stepCtx context.Context) (db.Domain, error) { return db.Query.FindDomainByDomain(ctx.Context(), w.db.RO(), req.Domain) }) if err != nil { @@ -74,7 +73,7 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh return err } - err = hydra.StepVoid(ctx, "claim-challenge", func(stepCtx context.Context) error { + err = hydra.StepVoid(ctx, "acquire-challenge", func(stepCtx context.Context) error { return db.Query.UpdateAcmeChallengeTryClaiming(stepCtx, w.db.RW(), db.UpdateAcmeChallengeTryClaimingParams{ DomainID: dom.ID, Status: db.AcmeChallengesStatusPending, @@ -86,7 +85,7 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh return err } - cert, err := hydra.Step(ctx, "get-and-encrypt-cert", func(stepCtx context.Context) (EncryptedCertificate, error) { + cert, err := hydra.Step(ctx, "obtain-certificate", func(stepCtx context.Context) (EncryptedCertificate, error) { // A certificate request can be either // A: We have a new domain WITHOUT a certificate // B: We have to renew a existing certificate @@ -153,11 +152,11 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh }, nil }) if err != nil { - w.logger.Error("failed to get and store certs in vault", "error", err) + w.logger.Error("failed to obtain certificate", "error", err) return err } - err = hydra.StepVoid(ctx, "store-cert", func(stepCtx context.Context) error { + err = hydra.StepVoid(ctx, "persist-certificate", func(stepCtx context.Context) error { now := time.Now().UnixMilli() return pdb.Query.InsertCertificate(stepCtx, w.partitionDB.RW(), pdb.InsertCertificateParams{ WorkspaceID: dom.WorkspaceID, @@ -169,19 +168,20 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh }) }) if err != nil { - w.logger.Error("failed to store cert in vault", "error", err) + w.logger.Error("failed to store certificate", "error", err) return err } - err = hydra.StepVoid(ctx, "set-expires-at", func(stepCtx context.Context) error { - return db.Query.UpdateAcmeChallengeExpiresAt(stepCtx, w.db.RW(), db.UpdateAcmeChallengeExpiresAtParams{ + err = hydra.StepVoid(ctx, "complete-challenge", func(stepCtx context.Context) error { + return db.Query.UpdateAcmeChallengeVerifiedWithExpiry(stepCtx, w.db.RW(), db.UpdateAcmeChallengeVerifiedWithExpiryParams{ + Status: db.AcmeChallengesStatusVerified, ExpiresAt: cert.ExpiresAt, - ID: 0, // "TODO: I need the challenge id" - + UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()}, + DomainID: dom.ID, }) }) if err != nil { - w.logger.Error("failed to store expires at", "error", err) + w.logger.Error("failed to complete challenge", "error", err) return err } diff --git a/go/apps/ctrl/services/acme/providers/cloudflare_provider.go b/go/apps/ctrl/services/acme/providers/cloudflare_provider.go index 27a9c20d61..73501dbf8d 100644 --- a/go/apps/ctrl/services/acme/providers/cloudflare_provider.go +++ b/go/apps/ctrl/services/acme/providers/cloudflare_provider.go @@ -13,6 +13,7 @@ import ( ) var _ challenge.Provider = (*CloudflareProvider)(nil) +var _ challenge.ProviderTimeout = (*CloudflareProvider)(nil) // CloudflareProvider implements the lego challenge.Provider interface for DNS-01 challenges // It uses Cloudflare DNS to store challenges and tracks them in the database @@ -68,12 +69,12 @@ func (p *CloudflareProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("failed to find domain: %w", err) } - p.logger.Info("presenting DNS challenge", "domain", domain, "token", token) + p.logger.Info("presenting dns challenge", "domain", domain, "token", "[REDACTED]") // Create the DNS challenge record using Cloudflare err = p.provider.Present(domain, token, keyAuth) if err != nil { - p.logger.Error("failed to present DNS challenge", "error", err, "domain", domain, "token", token) + p.logger.Error("failed to present dns challenge", "error", err, "domain", domain) return fmt.Errorf("failed to present DNS challenge: %w", err) } @@ -87,28 +88,25 @@ func (p *CloudflareProvider) Present(domain, token, keyAuth string) error { }) if err != nil { - p.logger.Error("failed to store challenge in database", "error", err, "domain", domain, "token", token) + p.logger.Error("failed to store challenge in database", "error", err, "domain", domain) // Don't cleanup DNS record - Let's Encrypt still needs it for validation // The DNS record will be cleaned up later in CleanUp() regardless of success/failure return fmt.Errorf("failed to store challenge: %w", err) } - p.logger.Info("DNS challenge presented successfully", "domain", domain, "token", token) - - // Give DNS time to propagate before Let's Encrypt validates - time.Sleep(30 * time.Second) + p.logger.Info("dns challenge presented successfully", "domain", domain) return nil } // CleanUp removes the DNS TXT record and updates the database func (p *CloudflareProvider) CleanUp(domain, token, keyAuth string) error { - p.logger.Info("cleaning up DNS challenge", "domain", domain, "token", token) + p.logger.Info("cleaning up dns challenge", "domain", domain) // Clean up the DNS record first err := p.provider.CleanUp(domain, token, keyAuth) if err != nil { - p.logger.Warn("failed to clean up DNS challenge record", "error", err, "domain", domain, "token", token) + p.logger.Warn("failed to clean up dns challenge record", "error", err, "domain", domain) } return nil diff --git a/go/apps/ctrl/services/acme/providers/http_provider.go b/go/apps/ctrl/services/acme/providers/http_provider.go index 64e5e1a031..e6ba667d53 100644 --- a/go/apps/ctrl/services/acme/providers/http_provider.go +++ b/go/apps/ctrl/services/acme/providers/http_provider.go @@ -12,6 +12,7 @@ import ( ) var _ challenge.Provider = (*HTTPProvider)(nil) +var _ challenge.ProviderTimeout = (*HTTPProvider)(nil) // HTTPProvider implements the lego challenge.Provider interface for HTTP-01 challenges // It stores challenges in the database where the gateway can retrieve them @@ -54,13 +55,10 @@ func (p *HTTPProvider) Present(domain, token, keyAuth string) error { }) if err != nil { - p.logger.Error("failed to store challenge", "error", err, "domain", domain, "token", token) + p.logger.Error("failed to store challenge", "error", err, "domain", domain) return fmt.Errorf("failed to store challenge: %w", err) } - // Give the database time to replicate before Let's Encrypt tries to validate - time.Sleep(2 * time.Second) - return nil } @@ -75,17 +73,25 @@ func (p *HTTPProvider) CleanUp(domain, token, keyAuth string) error { } // Clear the token and authorization so the gateway stops serving the challenge - err = db.Query.UpdateAcmeChallengePending(ctx, p.db.RW(), db.UpdateAcmeChallengePendingParams{ - DomainID: dom.ID, - Status: db.AcmeChallengesStatusPending, // Keep existing status, just clear token - Token: "", // Clear token - Authorization: "", // Clear authorization + // Don't change the status - it should remain as set by the certificate workflow + err = db.Query.ClearAcmeChallengeTokens(ctx, p.db.RW(), db.ClearAcmeChallengeTokensParams{ + Token: "", // Clear token + Authorization: "", // Clear authorization UpdatedAt: sql.NullInt64{Int64: time.Now().UnixMilli(), Valid: true}, + DomainID: dom.ID, }) if err != nil { - p.logger.Warn("failed to clean up challenge token", "error", err, "domain", domain, "token", token) + p.logger.Warn("failed to clean up challenge token", "error", err, "domain", domain) } return nil } + +// Timeout returns custom timeout and check interval for HTTP-01 challenges +// Returns (timeout, interval) - how long to wait and time between checks +func (p *HTTPProvider) Timeout() (time.Duration, time.Duration) { + // HTTP challenges typically resolve faster than DNS, but give some buffer + // 90 seconds timeout, 3 second check interval + return 90 * time.Second, 3 * time.Second +} diff --git a/go/apps/ctrl/services/deployment/deploy_workflow.go b/go/apps/ctrl/services/deployment/deploy_workflow.go index 02883ed021..6c3ffd2c61 100644 --- a/go/apps/ctrl/services/deployment/deploy_workflow.go +++ b/go/apps/ctrl/services/deployment/deploy_workflow.go @@ -122,13 +122,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return err } - // Get backend name for step naming - backendName := "unknown" - if w.deploymentBackend != nil { - backendName = w.deploymentBackend.Name() - } - - deployment, err := hydra.Step(ctx, fmt.Sprintf("%s-create-deployment", backendName), func(stepCtx context.Context) (*metaldv1.CreateDeploymentResponse, error) { + deployment, err := hydra.Step(ctx, "create-deployment", func(stepCtx context.Context) (*metaldv1.CreateDeploymentResponse, error) { if w.deploymentBackend == nil { return nil, fmt.Errorf("deployment backend not initialized") } @@ -146,14 +140,14 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro resp, err := w.deploymentBackend.CreateDeployment(stepCtx, deploymentReq) if err != nil { - w.logger.Error("CreateDeployment failed", "error", err, "docker_image", req.DockerImage) + w.logger.Error("create deployment failed", "error", err, "docker_image", req.DockerImage) return nil, fmt.Errorf("failed to create deployment: %w", err) } return resp, nil }) if err != nil { - w.logger.Error("Deployment failed", "error", err, "deployment_id", req.DeploymentID, "backend", backendName) + w.logger.Error("deployment failed", "error", err, "deployment_id", req.DeploymentID, "backend", w.deploymentBackend.Name()) return err } @@ -191,7 +185,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro vms, err := w.deploymentBackend.GetDeployment(stepCtx, req.DeploymentID) if err != nil { - w.logger.Error("GetDeployment failed", "error", err, "deployment_id", req.DeploymentID) + w.logger.Error("get deployment failed", "error", err, "deployment_id", req.DeploymentID) return nil, fmt.Errorf("failed to get deployment: %w", err) } @@ -255,7 +249,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return nil, fmt.Errorf("deployment never became ready") }) if err != nil { - w.logger.Error("Polling deployment prepare failed", "error", err, "deployment_id", req.DeploymentID) + w.logger.Error("polling deployment prepare failed", "error", err, "deployment_id", req.DeploymentID) return err } @@ -455,13 +449,13 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro resp, err := client.Get(openapiURL) if err != nil { - w.logger.Warn("OpenAPI scraping failed for host address", "error", err, "host_addr", hostAddr, "deployment_id", req.DeploymentID) + w.logger.Warn("openapi scraping failed for host address", "error", err, "host_addr", hostAddr, "deployment_id", req.DeploymentID) 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, "deployment_id", req.DeploymentID) + w.logger.Warn("openapi endpoint returned non-200 status", "status", resp.StatusCode, "host_addr", hostAddr, "deployment_id", req.DeploymentID) continue } @@ -472,7 +466,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro continue } - w.logger.Info("OpenAPI spec scraped successfully", "host_addr", hostAddr, "deployment_id", req.DeploymentID, "spec_size", len(specBytes)) + w.logger.Info("openapi spec scraped successfully", "host_addr", hostAddr, "deployment_id", req.DeploymentID, "spec_size", len(specBytes)) return string(specBytes), nil } @@ -562,7 +556,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return nil // Don't fail the deployment } - w.logger.Info("OpenAPI spec stored in database successfully", "deployment_id", req.DeploymentID, "spec_size", len(openapiSpec)) + w.logger.Info("openapi spec stored in database successfully", "deployment_id", req.DeploymentID, "spec_size", len(openapiSpec)) return nil }) if err != nil { @@ -631,7 +625,7 @@ func (w *DeployWorkflow) createGatewayConfigForHostname(ctx context.Context, wor // Validate partition DB connection if w.partitionDB == nil { - w.logger.Error("CRITICAL: partition database not initialized for gateway config") + w.logger.Error("critical: partition database not initialized for gateway config") return fmt.Errorf("partition database not initialized for gateway config") } diff --git a/go/pkg/db/acme_challenge_clear_tokens.sql_generated.go b/go/pkg/db/acme_challenge_clear_tokens.sql_generated.go new file mode 100644 index 0000000000..3bc5a0b8a9 --- /dev/null +++ b/go/pkg/db/acme_challenge_clear_tokens.sql_generated.go @@ -0,0 +1,39 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: acme_challenge_clear_tokens.sql + +package db + +import ( + "context" + "database/sql" +) + +const clearAcmeChallengeTokens = `-- name: ClearAcmeChallengeTokens :exec +UPDATE acme_challenges +SET token = ?, authorization = ?, updated_at = ? +WHERE domain_id = ? +` + +type ClearAcmeChallengeTokensParams struct { + Token string `db:"token"` + Authorization string `db:"authorization"` + UpdatedAt sql.NullInt64 `db:"updated_at"` + DomainID string `db:"domain_id"` +} + +// ClearAcmeChallengeTokens +// +// UPDATE acme_challenges +// SET token = ?, authorization = ?, updated_at = ? +// WHERE domain_id = ? +func (q *Queries) ClearAcmeChallengeTokens(ctx context.Context, db DBTX, arg ClearAcmeChallengeTokensParams) error { + _, err := db.ExecContext(ctx, clearAcmeChallengeTokens, + arg.Token, + arg.Authorization, + arg.UpdatedAt, + arg.DomainID, + ) + return err +} diff --git a/go/pkg/db/acme_challenge_update_verified_with_expiry.sql_generated.go b/go/pkg/db/acme_challenge_update_verified_with_expiry.sql_generated.go new file mode 100644 index 0000000000..bed1ff4713 --- /dev/null +++ b/go/pkg/db/acme_challenge_update_verified_with_expiry.sql_generated.go @@ -0,0 +1,39 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: acme_challenge_update_verified_with_expiry.sql + +package db + +import ( + "context" + "database/sql" +) + +const updateAcmeChallengeVerifiedWithExpiry = `-- name: UpdateAcmeChallengeVerifiedWithExpiry :exec +UPDATE acme_challenges +SET status = ?, expires_at = ?, updated_at = ? +WHERE domain_id = ? +` + +type UpdateAcmeChallengeVerifiedWithExpiryParams struct { + Status AcmeChallengesStatus `db:"status"` + ExpiresAt int64 `db:"expires_at"` + UpdatedAt sql.NullInt64 `db:"updated_at"` + DomainID string `db:"domain_id"` +} + +// UpdateAcmeChallengeVerifiedWithExpiry +// +// UPDATE acme_challenges +// SET status = ?, expires_at = ?, updated_at = ? +// WHERE domain_id = ? +func (q *Queries) UpdateAcmeChallengeVerifiedWithExpiry(ctx context.Context, db DBTX, arg UpdateAcmeChallengeVerifiedWithExpiryParams) error { + _, err := db.ExecContext(ctx, updateAcmeChallengeVerifiedWithExpiry, + arg.Status, + arg.ExpiresAt, + arg.UpdatedAt, + arg.DomainID, + ) + return err +} diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index 1838037244..bce6021c39 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -10,6 +10,12 @@ import ( ) type Querier interface { + //ClearAcmeChallengeTokens + // + // UPDATE acme_challenges + // SET token = ?, authorization = ?, updated_at = ? + // WHERE domain_id = ? + ClearAcmeChallengeTokens(ctx context.Context, db DBTX, arg ClearAcmeChallengeTokensParams) error //DeleteAllKeyPermissionsByKeyID // // DELETE FROM keys_permissions @@ -1562,6 +1568,12 @@ type Querier interface { // SET status = ?, updated_at = ? // WHERE domain_id = ? AND status = 'waiting' UpdateAcmeChallengeTryClaiming(ctx context.Context, db DBTX, arg UpdateAcmeChallengeTryClaimingParams) error + //UpdateAcmeChallengeVerifiedWithExpiry + // + // UPDATE acme_challenges + // SET status = ?, expires_at = ?, updated_at = ? + // WHERE domain_id = ? + UpdateAcmeChallengeVerifiedWithExpiry(ctx context.Context, db DBTX, arg UpdateAcmeChallengeVerifiedWithExpiryParams) error //UpdateAcmeUserRegistrationURI // // UPDATE acme_users SET registration_uri = ? WHERE id = ? diff --git a/go/pkg/db/queries/acme_challenge_clear_tokens.sql b/go/pkg/db/queries/acme_challenge_clear_tokens.sql new file mode 100644 index 0000000000..f2d08d5e02 --- /dev/null +++ b/go/pkg/db/queries/acme_challenge_clear_tokens.sql @@ -0,0 +1,4 @@ +-- name: ClearAcmeChallengeTokens :exec +UPDATE acme_challenges +SET token = ?, authorization = ?, updated_at = ? +WHERE domain_id = ?; \ No newline at end of file diff --git a/go/pkg/db/queries/acme_challenge_update_verified_with_expiry.sql b/go/pkg/db/queries/acme_challenge_update_verified_with_expiry.sql new file mode 100644 index 0000000000..1719764e96 --- /dev/null +++ b/go/pkg/db/queries/acme_challenge_update_verified_with_expiry.sql @@ -0,0 +1,4 @@ +-- name: UpdateAcmeChallengeVerifiedWithExpiry :exec +UPDATE acme_challenges +SET status = ?, expires_at = ?, updated_at = ? +WHERE domain_id = ?; \ No newline at end of file diff --git a/go/pkg/db/schema.sql b/go/pkg/db/schema.sql index 9a67348e57..eabd2d2027 100644 --- a/go/pkg/db/schema.sql +++ b/go/pkg/db/schema.sql @@ -385,7 +385,7 @@ CREATE TABLE `acme_challenges` ( `expires_at` bigint NOT NULL, `created_at` bigint NOT NULL, `updated_at` bigint, - CONSTRAINT `acme_challenges_id` PRIMARY KEY(`id`) + CONSTRAINT `acme_challenges_domain_id_pk` PRIMARY KEY(`domain_id`) ); CREATE INDEX `workspace_id_idx` ON `apis` (`workspace_id`); @@ -413,5 +413,6 @@ CREATE INDEX `status_idx` ON `deployments` (`status`); CREATE INDEX `domain_idx` ON `acme_users` (`workspace_id`); CREATE INDEX `workspace_idx` ON `domains` (`workspace_id`); CREATE INDEX `project_idx` ON `domains` (`project_id`); +CREATE INDEX `id_idx` ON `acme_challenges` (`id`); CREATE INDEX `workspace_idx` ON `acme_challenges` (`workspace_id`); diff --git a/internal/db/src/schema/acme_challenges.ts b/internal/db/src/schema/acme_challenges.ts index 9432a06a23..8792ce718c 100644 --- a/internal/db/src/schema/acme_challenges.ts +++ b/internal/db/src/schema/acme_challenges.ts @@ -1,5 +1,5 @@ import { relations } from "drizzle-orm"; -import { bigint, index, mysqlEnum, mysqlTable, varchar } from "drizzle-orm/mysql-core"; +import { bigint, index, mysqlEnum, mysqlTable, primaryKey, varchar } from "drizzle-orm/mysql-core"; import { domains } from "./domains"; import { lifecycleDates } from "./util/lifecycle_dates"; import { workspaces } from "./workspaces"; @@ -7,7 +7,7 @@ import { workspaces } from "./workspaces"; export const acmeChallenges = mysqlTable( "acme_challenges", { - id: bigint("id", { mode: "number", unsigned: true }).autoincrement().primaryKey(), + id: bigint("id", { mode: "number", unsigned: true }).autoincrement().notNull(), workspaceId: varchar("workspace_id", { length: 256 }).notNull(), domainId: varchar("domain_id", { length: 256 }).notNull(), token: varchar("token", { length: 256 }).notNull(), @@ -19,7 +19,9 @@ export const acmeChallenges = mysqlTable( ...lifecycleDates, }, (table) => ({ + idIdx: index("id_idx").on(table.id), workspaceIdx: index("workspace_idx").on(table.workspaceId), + pk: primaryKey({columns:[table.domainId]}), }), ); From b81f159bb7a508de0ab0b4bbc5eb5b3cfa9c07a0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:07:01 +0000 Subject: [PATCH 15/20] [autofix.ci] apply automated fixes --- internal/db/src/schema/acme_challenges.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/db/src/schema/acme_challenges.ts b/internal/db/src/schema/acme_challenges.ts index 8792ce718c..05fc5fd67a 100644 --- a/internal/db/src/schema/acme_challenges.ts +++ b/internal/db/src/schema/acme_challenges.ts @@ -21,7 +21,7 @@ export const acmeChallenges = mysqlTable( (table) => ({ idIdx: index("id_idx").on(table.id), workspaceIdx: index("workspace_idx").on(table.workspaceId), - pk: primaryKey({columns:[table.domainId]}), + pk: primaryKey({ columns: [table.domainId] }), }), ); From 4913d695981b79a932dce683625b7f2cb7409bf5 Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 12 Sep 2025 11:44:42 +0200 Subject: [PATCH 16/20] fix: update schema --- go/pkg/db/schema.sql | 10 ++++------ internal/db/src/schema/acme_challenges.ts | 12 +++++------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/go/pkg/db/schema.sql b/go/pkg/db/schema.sql index eabd2d2027..f4b6abbbfc 100644 --- a/go/pkg/db/schema.sql +++ b/go/pkg/db/schema.sql @@ -375,12 +375,11 @@ CREATE TABLE `domains` ( ); CREATE TABLE `acme_challenges` ( - `id` bigint unsigned AUTO_INCREMENT NOT NULL, - `workspace_id` varchar(256) NOT NULL, - `domain_id` varchar(256) NOT NULL, - `token` varchar(256) NOT NULL, + `domain_id` varchar(255) NOT NULL, + `workspace_id` varchar(255) NOT NULL, + `token` varchar(255) NOT NULL, `type` enum('HTTP-01','DNS-01') NOT NULL, - `authorization` varchar(256) NOT NULL, + `authorization` varchar(255) NOT NULL, `status` enum('waiting','pending','verified','failed') NOT NULL, `expires_at` bigint NOT NULL, `created_at` bigint NOT NULL, @@ -413,6 +412,5 @@ CREATE INDEX `status_idx` ON `deployments` (`status`); CREATE INDEX `domain_idx` ON `acme_users` (`workspace_id`); CREATE INDEX `workspace_idx` ON `domains` (`workspace_id`); CREATE INDEX `project_idx` ON `domains` (`project_id`); -CREATE INDEX `id_idx` ON `acme_challenges` (`id`); CREATE INDEX `workspace_idx` ON `acme_challenges` (`workspace_id`); diff --git a/internal/db/src/schema/acme_challenges.ts b/internal/db/src/schema/acme_challenges.ts index 05fc5fd67a..f7af5fcf44 100644 --- a/internal/db/src/schema/acme_challenges.ts +++ b/internal/db/src/schema/acme_challenges.ts @@ -7,21 +7,19 @@ import { workspaces } from "./workspaces"; export const acmeChallenges = mysqlTable( "acme_challenges", { - id: bigint("id", { mode: "number", unsigned: true }).autoincrement().notNull(), - workspaceId: varchar("workspace_id", { length: 256 }).notNull(), - domainId: varchar("domain_id", { length: 256 }).notNull(), - token: varchar("token", { length: 256 }).notNull(), + domainId: varchar("domain_id", { length: 255 }).notNull(), + workspaceId: varchar("workspace_id", { length: 255 }).notNull(), + token: varchar("token", { length: 255 }).notNull(), type: mysqlEnum("type", ["HTTP-01", "DNS-01"]).notNull(), - authorization: varchar("authorization", { length: 256 }).notNull(), + authorization: varchar("authorization", { length: 255 }).notNull(), status: mysqlEnum("status", ["waiting", "pending", "verified", "failed"]).notNull(), expiresAt: bigint("expires_at", { mode: "number" }).notNull(), ...lifecycleDates, }, (table) => ({ - idIdx: index("id_idx").on(table.id), - workspaceIdx: index("workspace_idx").on(table.workspaceId), pk: primaryKey({ columns: [table.domainId] }), + workspaceIdx: index("workspace_idx").on(table.workspaceId), }), ); From fb20d25ccf96bb07ae619b6faa0dca94d3c608f2 Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 12 Sep 2025 11:45:58 +0200 Subject: [PATCH 17/20] fix: update schema --- ...e_challenge_find_by_token.sql_generated.go | 7 +++-- ...challenge_list_executable.sql_generated.go | 12 +++------ ...allenge_update_expires_at.sql_generated.go | 27 ------------------- go/pkg/db/models_generated.go | 3 +-- go/pkg/db/querier_generated.go | 8 ++---- .../acme_challenge_list_executable.sql | 2 +- .../acme_challenge_update_expires_at.sql | 2 -- 7 files changed, 10 insertions(+), 51 deletions(-) delete mode 100644 go/pkg/db/acme_challenge_update_expires_at.sql_generated.go delete mode 100644 go/pkg/db/queries/acme_challenge_update_expires_at.sql diff --git a/go/pkg/db/acme_challenge_find_by_token.sql_generated.go b/go/pkg/db/acme_challenge_find_by_token.sql_generated.go index 284a782c9c..3567f241f6 100644 --- a/go/pkg/db/acme_challenge_find_by_token.sql_generated.go +++ b/go/pkg/db/acme_challenge_find_by_token.sql_generated.go @@ -10,7 +10,7 @@ import ( ) const findAcmeChallengeByToken = `-- name: FindAcmeChallengeByToken :one -SELECT id, workspace_id, domain_id, token, type, authorization, status, expires_at, created_at, updated_at FROM acme_challenges WHERE workspace_id = ? AND domain_id = ? AND token = ? +SELECT domain_id, workspace_id, token, type, authorization, status, expires_at, created_at, updated_at FROM acme_challenges WHERE workspace_id = ? AND domain_id = ? AND token = ? ` type FindAcmeChallengeByTokenParams struct { @@ -21,14 +21,13 @@ type FindAcmeChallengeByTokenParams struct { // FindAcmeChallengeByToken // -// SELECT id, workspace_id, domain_id, token, type, authorization, status, expires_at, created_at, updated_at FROM acme_challenges WHERE workspace_id = ? AND domain_id = ? AND token = ? +// SELECT domain_id, workspace_id, token, type, authorization, status, expires_at, created_at, updated_at FROM acme_challenges WHERE workspace_id = ? AND domain_id = ? AND token = ? func (q *Queries) FindAcmeChallengeByToken(ctx context.Context, db DBTX, arg FindAcmeChallengeByTokenParams) (AcmeChallenge, error) { row := db.QueryRowContext(ctx, findAcmeChallengeByToken, arg.WorkspaceID, arg.DomainID, arg.Token) var i AcmeChallenge err := row.Scan( - &i.ID, - &i.WorkspaceID, &i.DomainID, + &i.WorkspaceID, &i.Token, &i.Type, &i.Authorization, diff --git a/go/pkg/db/acme_challenge_list_executable.sql_generated.go b/go/pkg/db/acme_challenge_list_executable.sql_generated.go index 6f69d88e2e..8f0847476d 100644 --- a/go/pkg/db/acme_challenge_list_executable.sql_generated.go +++ b/go/pkg/db/acme_challenge_list_executable.sql_generated.go @@ -11,7 +11,7 @@ import ( ) const listExecutableChallenges = `-- name: ListExecutableChallenges :many -SELECT dc.id, dc.workspace_id, dc.type, d.domain FROM acme_challenges dc +SELECT dc.workspace_id, dc.type, d.domain FROM acme_challenges dc JOIN domains d ON dc.domain_id = d.id WHERE (dc.status = 'waiting' OR (dc.status = 'verified' AND dc.expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY))) AND dc.type IN (/*SLICE:verification_types*/?) @@ -19,7 +19,6 @@ ORDER BY d.created_at ASC ` type ListExecutableChallengesRow struct { - ID uint64 `db:"id"` WorkspaceID string `db:"workspace_id"` Type AcmeChallengesType `db:"type"` Domain string `db:"domain"` @@ -27,7 +26,7 @@ type ListExecutableChallengesRow struct { // ListExecutableChallenges // -// SELECT dc.id, dc.workspace_id, dc.type, d.domain FROM acme_challenges dc +// SELECT dc.workspace_id, dc.type, d.domain FROM acme_challenges dc // JOIN domains d ON dc.domain_id = d.id // WHERE (dc.status = 'waiting' OR (dc.status = 'verified' AND dc.expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY))) // AND dc.type IN (/*SLICE:verification_types*/?) @@ -51,12 +50,7 @@ func (q *Queries) ListExecutableChallenges(ctx context.Context, db DBTX, verific var items []ListExecutableChallengesRow for rows.Next() { var i ListExecutableChallengesRow - if err := rows.Scan( - &i.ID, - &i.WorkspaceID, - &i.Type, - &i.Domain, - ); err != nil { + if err := rows.Scan(&i.WorkspaceID, &i.Type, &i.Domain); err != nil { return nil, err } items = append(items, i) diff --git a/go/pkg/db/acme_challenge_update_expires_at.sql_generated.go b/go/pkg/db/acme_challenge_update_expires_at.sql_generated.go deleted file mode 100644 index 9097d73a1a..0000000000 --- a/go/pkg/db/acme_challenge_update_expires_at.sql_generated.go +++ /dev/null @@ -1,27 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: acme_challenge_update_expires_at.sql - -package db - -import ( - "context" -) - -const updateAcmeChallengeExpiresAt = `-- name: UpdateAcmeChallengeExpiresAt :exec -UPDATE acme_challenges SET expires_at = ? WHERE id = ? -` - -type UpdateAcmeChallengeExpiresAtParams struct { - ExpiresAt int64 `db:"expires_at"` - ID uint64 `db:"id"` -} - -// UpdateAcmeChallengeExpiresAt -// -// UPDATE acme_challenges SET expires_at = ? WHERE id = ? -func (q *Queries) UpdateAcmeChallengeExpiresAt(ctx context.Context, db DBTX, arg UpdateAcmeChallengeExpiresAtParams) error { - _, err := db.ExecContext(ctx, updateAcmeChallengeExpiresAt, arg.ExpiresAt, arg.ID) - return err -} diff --git a/go/pkg/db/models_generated.go b/go/pkg/db/models_generated.go index c4f20fc5ce..3096b5e342 100644 --- a/go/pkg/db/models_generated.go +++ b/go/pkg/db/models_generated.go @@ -448,9 +448,8 @@ func (ns NullWorkspacesPlan) Value() (driver.Value, error) { } type AcmeChallenge struct { - ID uint64 `db:"id"` - WorkspaceID string `db:"workspace_id"` DomainID string `db:"domain_id"` + WorkspaceID string `db:"workspace_id"` Token string `db:"token"` Type AcmeChallengesType `db:"type"` Authorization string `db:"authorization"` diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index bce6021c39..c44a285d4d 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -121,7 +121,7 @@ type Querier interface { DeleteRoleByID(ctx context.Context, db DBTX, roleID string) error //FindAcmeChallengeByToken // - // SELECT id, workspace_id, domain_id, token, type, authorization, status, expires_at, created_at, updated_at FROM acme_challenges WHERE workspace_id = ? AND domain_id = ? AND token = ? + // SELECT domain_id, workspace_id, token, type, authorization, status, expires_at, created_at, updated_at FROM acme_challenges WHERE workspace_id = ? AND domain_id = ? AND token = ? FindAcmeChallengeByToken(ctx context.Context, db DBTX, arg FindAcmeChallengeByTokenParams) (AcmeChallenge, error) //FindAcmeUserByWorkspaceID // @@ -1224,7 +1224,7 @@ type Querier interface { ListDirectPermissionsByKeyID(ctx context.Context, db DBTX, keyID string) ([]Permission, error) //ListExecutableChallenges // - // SELECT dc.id, dc.workspace_id, dc.type, d.domain FROM acme_challenges dc + // SELECT dc.workspace_id, dc.type, d.domain FROM acme_challenges dc // JOIN domains d ON dc.domain_id = d.id // WHERE (dc.status = 'waiting' OR (dc.status = 'verified' AND dc.expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY))) // AND dc.type IN (/*SLICE:verification_types*/?) @@ -1546,10 +1546,6 @@ type Querier interface { // WHERE id = ? // AND delete_protection = false SoftDeleteWorkspace(ctx context.Context, db DBTX, arg SoftDeleteWorkspaceParams) (sql.Result, error) - //UpdateAcmeChallengeExpiresAt - // - // UPDATE acme_challenges SET expires_at = ? WHERE id = ? - UpdateAcmeChallengeExpiresAt(ctx context.Context, db DBTX, arg UpdateAcmeChallengeExpiresAtParams) error //UpdateAcmeChallengePending // // UPDATE acme_challenges diff --git a/go/pkg/db/queries/acme_challenge_list_executable.sql b/go/pkg/db/queries/acme_challenge_list_executable.sql index 5e888a87ba..3ec745051f 100644 --- a/go/pkg/db/queries/acme_challenge_list_executable.sql +++ b/go/pkg/db/queries/acme_challenge_list_executable.sql @@ -1,5 +1,5 @@ -- name: ListExecutableChallenges :many -SELECT dc.id, dc.workspace_id, dc.type, d.domain FROM acme_challenges dc +SELECT dc.workspace_id, dc.type, d.domain FROM acme_challenges dc JOIN domains d ON dc.domain_id = d.id WHERE (dc.status = 'waiting' OR (dc.status = 'verified' AND dc.expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY))) AND dc.type IN (sqlc.slice(verification_types)) diff --git a/go/pkg/db/queries/acme_challenge_update_expires_at.sql b/go/pkg/db/queries/acme_challenge_update_expires_at.sql deleted file mode 100644 index 0de8f2cc69..0000000000 --- a/go/pkg/db/queries/acme_challenge_update_expires_at.sql +++ /dev/null @@ -1,2 +0,0 @@ --- name: UpdateAcmeChallengeExpiresAt :exec -UPDATE acme_challenges SET expires_at = ? WHERE id = ?; From f3c91bdb95d2a6927c885e0af7d447221434482d Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 12 Sep 2025 12:02:37 +0200 Subject: [PATCH 18/20] fix: remove double lkogging --- go/apps/ctrl/run.go | 1 - .../services/acme/certificate_workflow.go | 9 +-- .../acme/providers/cloudflare_provider.go | 9 +-- .../services/acme/providers/http_provider.go | 9 +-- .../ctrl/services/deployment/backends/k8s.go | 5 +- .../services/deployment/deploy_workflow.go | 76 ++----------------- go/apps/gw/run.go | 1 - go/apps/gw/services/caches/caches.go | 2 +- go/pkg/db/schema.sql | 1 + internal/db/src/schema/acme_challenges.ts | 1 + 10 files changed, 19 insertions(+), 95 deletions(-) diff --git a/go/apps/ctrl/run.go b/go/apps/ctrl/run.go index e711f62c20..41b9adfb25 100644 --- a/go/apps/ctrl/run.go +++ b/go/apps/ctrl/run.go @@ -284,7 +284,6 @@ func Run(ctx context.Context, cfg Config) error { }) err = acmeClient.Challenge.SetHTTP01Provider(httpProvider) if err != nil { - logger.Error("failed to set HTTP-01 provider", "error", err) return fmt.Errorf("failed to set HTTP-01 provider: %w", err) } diff --git a/go/apps/ctrl/services/acme/certificate_workflow.go b/go/apps/ctrl/services/acme/certificate_workflow.go index 2e5c5ad6da..f7175aac06 100644 --- a/go/apps/ctrl/services/acme/certificate_workflow.go +++ b/go/apps/ctrl/services/acme/certificate_workflow.go @@ -66,10 +66,9 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh w.logger.Info("starting certificate challenge", "workspace_id", req.WorkspaceID, "domain", req.Domain) dom, err := hydra.Step(ctx, "resolve-domain", func(stepCtx context.Context) (db.Domain, error) { - return db.Query.FindDomainByDomain(ctx.Context(), w.db.RO(), req.Domain) + return db.Query.FindDomainByDomain(stepCtx, w.db.RO(), req.Domain) }) if err != nil { - w.logger.Error("failed to find domain", "error", err) return err } @@ -81,7 +80,6 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh }) }) if err != nil { - w.logger.Error("failed to claim challenge", "error", err) return err } @@ -96,7 +94,6 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh Status: db.AcmeChallengesStatusFailed, UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()}, }) - w.logger.Error("failed to obtain certificate", "error", err) return EncryptedCertificate{}, err } @@ -128,7 +125,6 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh }) } if err != nil { - w.logger.Error("failed to renew/issue certificate", "error", err) return EncryptedCertificate{}, err } @@ -152,7 +148,6 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh }, nil }) if err != nil { - w.logger.Error("failed to obtain certificate", "error", err) return err } @@ -168,7 +163,6 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh }) }) if err != nil { - w.logger.Error("failed to store certificate", "error", err) return err } @@ -181,7 +175,6 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh }) }) if err != nil { - w.logger.Error("failed to complete challenge", "error", err) return err } diff --git a/go/apps/ctrl/services/acme/providers/cloudflare_provider.go b/go/apps/ctrl/services/acme/providers/cloudflare_provider.go index 73501dbf8d..ec48b46334 100644 --- a/go/apps/ctrl/services/acme/providers/cloudflare_provider.go +++ b/go/apps/ctrl/services/acme/providers/cloudflare_provider.go @@ -65,8 +65,7 @@ func (p *CloudflareProvider) Present(domain, token, keyAuth string) error { dom, err := db.Query.FindDomainByDomain(ctx, p.db.RO(), searchDomain) if err != nil { - p.logger.Error("failed to find domain", "error", err, "domain", searchDomain) - return fmt.Errorf("failed to find domain: %w", err) + return fmt.Errorf("failed to find domain %s: %w", searchDomain, err) } p.logger.Info("presenting dns challenge", "domain", domain, "token", "[REDACTED]") @@ -74,8 +73,7 @@ func (p *CloudflareProvider) Present(domain, token, keyAuth string) error { // Create the DNS challenge record using Cloudflare err = p.provider.Present(domain, token, keyAuth) if err != nil { - p.logger.Error("failed to present dns challenge", "error", err, "domain", domain) - return fmt.Errorf("failed to present DNS challenge: %w", err) + return fmt.Errorf("failed to present DNS challenge for domain %s: %w", domain, err) } // Update the database to track the challenge @@ -88,10 +86,9 @@ func (p *CloudflareProvider) Present(domain, token, keyAuth string) error { }) if err != nil { - p.logger.Error("failed to store challenge in database", "error", err, "domain", domain) // Don't cleanup DNS record - Let's Encrypt still needs it for validation // The DNS record will be cleaned up later in CleanUp() regardless of success/failure - return fmt.Errorf("failed to store challenge: %w", err) + return fmt.Errorf("failed to store challenge for domain %s: %w", domain, err) } p.logger.Info("dns challenge presented successfully", "domain", domain) diff --git a/go/apps/ctrl/services/acme/providers/http_provider.go b/go/apps/ctrl/services/acme/providers/http_provider.go index e6ba667d53..370302b17b 100644 --- a/go/apps/ctrl/services/acme/providers/http_provider.go +++ b/go/apps/ctrl/services/acme/providers/http_provider.go @@ -41,8 +41,7 @@ func (p *HTTPProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() dom, err := db.Query.FindDomainByDomain(ctx, p.db.RO(), domain) if err != nil { - p.logger.Error("failed to find domain", "error", err, "domain", domain) - return fmt.Errorf("failed to find domain: %w", err) + return fmt.Errorf("failed to find domain %s: %w", domain, err) } // Update the existing challenge record with the token and authorization @@ -55,8 +54,7 @@ func (p *HTTPProvider) Present(domain, token, keyAuth string) error { }) if err != nil { - p.logger.Error("failed to store challenge", "error", err, "domain", domain) - return fmt.Errorf("failed to store challenge: %w", err) + return fmt.Errorf("failed to store challenge for domain %s: %w", domain, err) } return nil @@ -68,8 +66,7 @@ func (p *HTTPProvider) CleanUp(domain, token, keyAuth string) error { dom, err := db.Query.FindDomainByDomain(ctx, p.db.RO(), domain) if err != nil { - p.logger.Error("failed to find domain during cleanup", "error", err, "domain", domain) - return fmt.Errorf("failed to find domain: %w", err) + return fmt.Errorf("failed to find domain %s during cleanup: %w", domain, err) } // Clear the token and authorization so the gateway stops serving the challenge diff --git a/go/apps/ctrl/services/deployment/backends/k8s.go b/go/apps/ctrl/services/deployment/backends/k8s.go index 28c082be63..8324a7ea7b 100644 --- a/go/apps/ctrl/services/deployment/backends/k8s.go +++ b/go/apps/ctrl/services/deployment/backends/k8s.go @@ -273,10 +273,7 @@ func (k *K8sBackend) DeleteDeployment(ctx context.Context, deploymentID string) // Delete deployment if err := k.clientset.AppsV1().Deployments(k.namespace).Delete(ctx, deploymentInfo.DeploymentName, metav1.DeleteOptions{}); err != nil { - k.logger.Error("failed to delete deployment", - "deployment_name", deploymentInfo.DeploymentName, - "error", err) - return err + return fmt.Errorf("failed to delete deployment %s: %w", deploymentInfo.DeploymentName, err) } k.logger.Info("Kubernetes deployment deleted", diff --git a/go/apps/ctrl/services/deployment/deploy_workflow.go b/go/apps/ctrl/services/deployment/deploy_workflow.go index 6c3ffd2c61..4152b83d38 100644 --- a/go/apps/ctrl/services/deployment/deploy_workflow.go +++ b/go/apps/ctrl/services/deployment/deploy_workflow.go @@ -101,7 +101,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro }) }) if err != nil { - w.logger.Error("failed to log deployment pending", "error", err, "deployment_id", req.DeploymentID) return err } @@ -118,7 +117,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return &struct{}{}, nil }) if err != nil { - w.logger.Error("failed to initialize build", "error", err, "deployment_id", req.DeploymentID) return err } @@ -140,14 +138,12 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro resp, err := w.deploymentBackend.CreateDeployment(stepCtx, deploymentReq) if err != nil { - w.logger.Error("create deployment failed", "error", err, "docker_image", req.DockerImage) - return nil, fmt.Errorf("failed to create deployment: %w", err) + return nil, fmt.Errorf("failed to create deployment for image %s: %w", req.DockerImage, err) } return resp, nil }) if err != nil { - w.logger.Error("deployment failed", "error", err, "deployment_id", req.DeploymentID, "backend", w.deploymentBackend.Name()) return err } @@ -166,7 +162,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return &struct{}{}, nil }) if err != nil { - w.logger.Error("failed to update version status to deploying", "error", err, "deployment_id", req.DeploymentID) return err } @@ -185,8 +180,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro vms, err := w.deploymentBackend.GetDeployment(stepCtx, req.DeploymentID) if err != nil { - w.logger.Error("get deployment failed", "error", err, "deployment_id", req.DeploymentID) - return nil, fmt.Errorf("failed to get deployment: %w", err) + return nil, fmt.Errorf("failed to get deployment %s: %w", req.DeploymentID, err) } allReady := true @@ -209,7 +203,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro "status", "running") if err := partitiondb.Query.UpsertVM(stepCtx, w.partitionDB.RW(), upsertParams); err != nil { - w.logger.Error("failed to upsert VM", "error", err, "vm_id", instance.Id, "params", upsertParams) return nil, fmt.Errorf("failed to upsert VM %s: %w", instance.Id, err) } @@ -249,13 +242,11 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return nil, fmt.Errorf("deployment never became ready") }) if err != nil { - w.logger.Error("polling deployment prepare failed", "error", err, "deployment_id", req.DeploymentID) return err } // Generate all domains (custom + auto-generated) allDomains, err := hydra.Step(ctx, "generate-all-domains", func(stepCtx context.Context) ([]string, error) { - var domains []string // Add custom hostname if provided @@ -292,7 +283,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return domains, nil }) if err != nil { - w.logger.Error("failed to generate domains", "error", err, "deployment_id", req.DeploymentID) return err } @@ -319,8 +309,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro // Perform bulk insert if err := db.BulkQuery.InsertDomains(stepCtx, w.db.RW(), domainParams); err != nil { - w.logger.Error("failed to create domain entries in bulk", "error", err, "deployment_id", req.DeploymentID, "domain_count", len(allDomains)) - return fmt.Errorf("failed to create domain entries: %w", err) + return fmt.Errorf("failed to create %d domain entries for deployment %s: %w", len(allDomains), req.DeploymentID, err) } w.logger.Info("domain entries created in bulk", "deployment_id", req.DeploymentID, "domain_count", len(allDomains)) @@ -328,7 +317,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return nil }) if err != nil { - w.logger.Error("failed to create domain entries", "error", err, "deployment_id", req.DeploymentID) return err } @@ -373,8 +361,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro // Perform bulk upsert for all gateway configs if len(gatewayParams) > 0 { if err := partitiondb.BulkQuery.UpsertGateway(stepCtx, w.partitionDB.RW(), gatewayParams); err != nil { - w.logger.Error("failed to upsert gateway configs in bulk", "error", err, "deployment_id", req.DeploymentID, "config_count", len(gatewayParams)) - return fmt.Errorf("failed to upsert gateway configs: %w", err) + return fmt.Errorf("failed to upsert %d gateway configs for deployment %s: %w", len(gatewayParams), req.DeploymentID, err) } } @@ -386,7 +373,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro }) }) if err != nil { - w.logger.Error("failed to create gateway configurations in bulk", "error", err, "deployment_id", req.DeploymentID) return err } @@ -400,8 +386,7 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro UpdatedAt: sql.NullInt64{Valid: true, Int64: completionTime}, }) if activeErr != nil { - w.logger.Error("failed to update deployment status to ready", "error", activeErr, "deployment_id", req.DeploymentID) - return nil, fmt.Errorf("failed to update deployment status to ready: %w", activeErr) + return nil, fmt.Errorf("failed to update deployment %s status to ready: %w", req.DeploymentID, activeErr) } w.logger.Info("deployment status updated to ready", "deployment_id", req.DeploymentID) @@ -411,7 +396,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro }, nil }) if err != nil { - w.logger.Error("deployment failed", "error", err, "deployment_id", req.DeploymentID) return err } /* @@ -473,7 +457,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro 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, "deployment_id", req.DeploymentID) return err } @@ -493,16 +476,14 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro // Fetch existing gateway config existingConfig, err := partitiondb.Query.FindGatewayByHostname(stepCtx, w.partitionDB.RO(), req.Hostname) if err != nil { - w.logger.Error("failed to fetch existing gateway config", "error", err, "hostname", req.Hostname) - return fmt.Errorf("failed to fetch existing gateway config: %w", err) + return fmt.Errorf("failed to fetch existing gateway config for %s: %w", req.Hostname, err) } // Unmarshal existing config // IMPORTANT: Gateway configs are stored as JSON in the database for compatibility with the gateway service var gatewayConfig partitionv1.GatewayConfig if err := protojson.Unmarshal(existingConfig.Config, &gatewayConfig); err != nil { - w.logger.Error("failed to unmarshal existing gateway config", "error", err, "hostname", req.Hostname) - return fmt.Errorf("failed to unmarshal existing gateway config: %w", err) + return fmt.Errorf("failed to unmarshal existing gateway config for %s: %w", req.Hostname, err) } // Add or update ValidationConfig with OpenAPI spec @@ -515,7 +496,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro // Gateway configs must be stored as JSON for compatibility with the gateway service configBytes, err := protojson.Marshal(&gatewayConfig) if err != nil { - w.logger.Error("failed to marshal updated gateway config", "error", err) return fmt.Errorf("failed to marshal updated gateway config: %w", err) } @@ -526,15 +506,13 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro } if err := partitiondb.Query.UpsertGateway(stepCtx, w.partitionDB.RW(), params); err != nil { - w.logger.Error("failed to update gateway config with OpenAPI spec", "error", err, "hostname", req.Hostname) - return fmt.Errorf("failed to update gateway config with OpenAPI spec: %w", err) + return fmt.Errorf("failed to update gateway config with OpenAPI spec for %s: %w", req.Hostname, err) } w.logger.Info("gateway config updated with OpenAPI spec successfully", "hostname", req.Hostname, "deployment_id", req.DeploymentID) return nil }) if err != nil { - w.logger.Error("failed to update gateway config with OpenAPI spec", "error", err, "deployment_id", req.DeploymentID) // Don't fail the deployment for this } @@ -560,7 +538,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro return nil }) if err != nil { - w.logger.Error("failed to store OpenAPI spec", "error", err, "deployment_id", req.DeploymentID) return err } @@ -575,7 +552,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro }) }) if err != nil { - w.logger.Error("failed to log completed", "error", err, "deployment_id", req.DeploymentID) return err } @@ -620,42 +596,6 @@ func (w *DeployWorkflow) createGatewayConfig(deploymentID, keyspaceID string, vm return gatewayConfig, nil } -// createGatewayConfigForHostname creates a gateway configuration for a specific hostname -func (w *DeployWorkflow) createGatewayConfigForHostname(ctx context.Context, workspaceID, hostname, deploymentID, keyspaceID string, vms []*metaldv1.GetDeploymentResponse_Vm) error { - - // Validate partition DB connection - if w.partitionDB == nil { - w.logger.Error("critical: partition database not initialized for gateway config") - return fmt.Errorf("partition database not initialized for gateway config") - } - - // Create gateway config - gatewayConfig, err := w.createGatewayConfig(deploymentID, keyspaceID, vms) - if err != nil { - return fmt.Errorf("failed to create gateway config: %w", err) - } - - // Marshal protobuf to bytes - configBytes, err := protojson.Marshal(gatewayConfig) - if err != nil { - w.logger.Error("failed to marshal gateway config", "error", err) - return fmt.Errorf("failed to marshal gateway config: %w", err) - } - - // Insert gateway config into partition database - params := partitiondb.UpsertGatewayParams{ - WorkspaceID: workspaceID, - Hostname: hostname, - Config: configBytes, - } - - if err := partitiondb.Query.UpsertGateway(ctx, w.partitionDB.RW(), params); err != nil { - w.logger.Error("failed to upsert gateway config", "error", err, "hostname", hostname) - return fmt.Errorf("failed to upsert gateway config: %w", err) - } - return nil -} - // isLocalHostname checks if a hostname is for local development func isLocalHostname(hostname string) bool { // Lowercase for case-insensitive comparison diff --git a/go/apps/gw/run.go b/go/apps/gw/run.go index c5058b9e81..8ef2b28680 100644 --- a/go/apps/gw/run.go +++ b/go/apps/gw/run.go @@ -323,7 +323,6 @@ func Run(ctx context.Context, cfg Config) error { // Wait for either OS signals or context cancellation, then shutdown if err := shutdowns.WaitForSignal(ctx, time.Minute); err != nil { - logger.Error("Shutdown failed", "error", err) return fmt.Errorf("shutdown failed: %w", err) } diff --git a/go/apps/gw/services/caches/caches.go b/go/apps/gw/services/caches/caches.go index baa1779d69..88040c644d 100644 --- a/go/apps/gw/services/caches/caches.go +++ b/go/apps/gw/services/caches/caches.go @@ -73,7 +73,7 @@ type Config struct { // key, err := caches.KeyByHash.Get(ctx, "some-hash") func New(config Config) (Caches, error) { gatewayConfig, err := cache.New(cache.Config[string, *partitionv1.GatewayConfig]{ - Fresh: time.Minute, + Fresh: time.Second * 5, Stale: time.Second * 30, Logger: config.Logger, MaxSize: 10_000, diff --git a/go/pkg/db/schema.sql b/go/pkg/db/schema.sql index f4b6abbbfc..ae4bd0b352 100644 --- a/go/pkg/db/schema.sql +++ b/go/pkg/db/schema.sql @@ -413,4 +413,5 @@ CREATE INDEX `domain_idx` ON `acme_users` (`workspace_id`); CREATE INDEX `workspace_idx` ON `domains` (`workspace_id`); CREATE INDEX `project_idx` ON `domains` (`project_id`); CREATE INDEX `workspace_idx` ON `acme_challenges` (`workspace_id`); +CREATE INDEX `status_idx` ON `acme_challenges` (`status`); diff --git a/internal/db/src/schema/acme_challenges.ts b/internal/db/src/schema/acme_challenges.ts index f7af5fcf44..43bea0f0ac 100644 --- a/internal/db/src/schema/acme_challenges.ts +++ b/internal/db/src/schema/acme_challenges.ts @@ -20,6 +20,7 @@ export const acmeChallenges = mysqlTable( (table) => ({ pk: primaryKey({ columns: [table.domainId] }), workspaceIdx: index("workspace_idx").on(table.workspaceId), + statusIdx: index("status_idx").on(table.status), }), ); From f9ab3ca115f650878e0b9b4fc59d02ce3b38d77a Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 12 Sep 2025 12:22:51 +0200 Subject: [PATCH 19/20] fix: schema --- internal/db/src/schema/acme_challenges.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/db/src/schema/acme_challenges.ts b/internal/db/src/schema/acme_challenges.ts index 43bea0f0ac..bc0127e6a3 100644 --- a/internal/db/src/schema/acme_challenges.ts +++ b/internal/db/src/schema/acme_challenges.ts @@ -1,5 +1,12 @@ import { relations } from "drizzle-orm"; -import { bigint, index, mysqlEnum, mysqlTable, primaryKey, varchar } from "drizzle-orm/mysql-core"; +import { + bigint, + index, + mysqlEnum, + mysqlTable, + primaryKey, + varchar, +} from "drizzle-orm/mysql-core"; import { domains } from "./domains"; import { lifecycleDates } from "./util/lifecycle_dates"; import { workspaces } from "./workspaces"; @@ -12,7 +19,12 @@ export const acmeChallenges = mysqlTable( token: varchar("token", { length: 255 }).notNull(), type: mysqlEnum("type", ["HTTP-01", "DNS-01"]).notNull(), authorization: varchar("authorization", { length: 255 }).notNull(), - status: mysqlEnum("status", ["waiting", "pending", "verified", "failed"]).notNull(), + status: mysqlEnum("status", [ + "waiting", + "pending", + "verified", + "failed", + ]).notNull(), expiresAt: bigint("expires_at", { mode: "number" }).notNull(), ...lifecycleDates, @@ -21,7 +33,7 @@ export const acmeChallenges = mysqlTable( pk: primaryKey({ columns: [table.domainId] }), workspaceIdx: index("workspace_idx").on(table.workspaceId), statusIdx: index("status_idx").on(table.status), - }), + }) ); export const acmeChallengeRelations = relations(acmeChallenges, ({ one }) => ({ From f3bdd222ac25aae4160cd04fe2dd8b63d80d28b6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:24:03 +0000 Subject: [PATCH 20/20] [autofix.ci] apply automated fixes --- internal/db/src/schema/acme_challenges.ts | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/internal/db/src/schema/acme_challenges.ts b/internal/db/src/schema/acme_challenges.ts index bc0127e6a3..43bea0f0ac 100644 --- a/internal/db/src/schema/acme_challenges.ts +++ b/internal/db/src/schema/acme_challenges.ts @@ -1,12 +1,5 @@ import { relations } from "drizzle-orm"; -import { - bigint, - index, - mysqlEnum, - mysqlTable, - primaryKey, - varchar, -} from "drizzle-orm/mysql-core"; +import { bigint, index, mysqlEnum, mysqlTable, primaryKey, varchar } from "drizzle-orm/mysql-core"; import { domains } from "./domains"; import { lifecycleDates } from "./util/lifecycle_dates"; import { workspaces } from "./workspaces"; @@ -19,12 +12,7 @@ export const acmeChallenges = mysqlTable( token: varchar("token", { length: 255 }).notNull(), type: mysqlEnum("type", ["HTTP-01", "DNS-01"]).notNull(), authorization: varchar("authorization", { length: 255 }).notNull(), - status: mysqlEnum("status", [ - "waiting", - "pending", - "verified", - "failed", - ]).notNull(), + status: mysqlEnum("status", ["waiting", "pending", "verified", "failed"]).notNull(), expiresAt: bigint("expires_at", { mode: "number" }).notNull(), ...lifecycleDates, @@ -33,7 +21,7 @@ export const acmeChallenges = mysqlTable( pk: primaryKey({ columns: [table.domainId] }), workspaceIdx: index("workspace_idx").on(table.workspaceId), statusIdx: index("status_idx").on(table.status), - }) + }), ); export const acmeChallengeRelations = relations(acmeChallenges, ({ one }) => ({