Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/ai-workspace/bottom-up-ai-artifact-deployment-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,17 @@ You can create artifacts on a gateway while it is disconnected, and they reconci

---

## Connecting to a new AI Workspace

Whenever a gateway (re)connects, it re-offers **all** of its gateway-created artifacts to the AI Workspace — not only the ones that have never synced. This means a gateway will populate a **new or reset AI Workspace** automatically:

- Point the gateway at a **different AI Workspace**, or re-register it, and all its artifacts sync to the new one.
- If the AI Workspace database is **purged or restored from an empty state**, the gateway repopulates it on the next connection.

Re-offering already-synced artifacts is safe: the AI Workspace recognizes an unchanged re-push and does **not** create duplicate deployments or overwrite edits you made to runtime-neutral details. A genuinely newer change from the gateway still wins, exactly as during normal syncing.

---

## Immutable gateways

Some gateways run in **immutable** mode, where artifacts are loaded from on-disk configuration at startup rather than created through the management API (see [Immutable Gateway](../gateway/immutable-gateway.md)).
Expand Down
10 changes: 10 additions & 0 deletions gateway/gateway-controller/pkg/api/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,16 @@ func (m *MockStorage) GetPendingCPSyncArtifacts() ([]*models.StoredConfig, error
return pending, nil
}

func (m *MockStorage) GetGatewayOriginArtifactsForSync() ([]*models.StoredConfig, error) {
var all []*models.StoredConfig
for _, config := range m.configs {
if config != nil && config.Origin == models.OriginGatewayAPI {
all = append(all, config)
}
}
return all, nil
}

// MockControlPlaneClient implements controlplane.ControlPlaneClient for testing
type MockControlPlaneClient struct {
connected bool
Expand Down
10 changes: 10 additions & 0 deletions gateway/gateway-controller/pkg/controlplane/api_deleted_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,16 @@ func (m *mockStorageForDeletion) GetPendingCPSyncArtifacts() ([]*models.StoredCo
return pending, nil
}

func (m *mockStorageForDeletion) GetGatewayOriginArtifactsForSync() ([]*models.StoredConfig, error) {
var all []*models.StoredConfig
for _, config := range m.configs {
if config.Origin == models.OriginGatewayAPI {
all = append(all, config)
}
}
return all, nil
}

// Helper to create test API config for deletion tests
func createTestAPIConfigForDeletion(apiID string) *models.StoredConfig {
// Create a complete API configuration so deletion flow can properly process it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,11 @@ func TestPushGatewayArtifacts(t *testing.T) {

client.apiUtilsService.SetBaseURL(server.URL)

// Seed a pending gateway-originated artifact (should be pushed), a CP-originated artifact
// (skipped: not gateway-originated), and an already-synced gateway artifact (skipped: only
// cp_sync_status pending/failed is retried).
// Seed a pending gateway-originated artifact (pushed), a CP-originated artifact
// (skipped: not gateway-originated), and an already-synced gateway artifact. The full
// reconcile re-pushes ALL gateway-origin artifacts regardless of cp_sync_status so a
// new/purged control plane (or a re-registered gateway) receives them (#2659); the CP
// dedupes replay re-pushes idempotently.
if err := client.db.SaveConfig(&models.StoredConfig{
UUID: "gw-artifact-1",
Kind: models.KindRestApi,
Expand Down Expand Up @@ -244,7 +246,7 @@ func TestPushGatewayArtifacts(t *testing.T) {
}); err != nil {
t.Fatalf("seed cp config: %v", err)
}
// Already-synced gateway artifact: must NOT be re-pushed on reconnect.
// Already-synced gateway artifact: with full reconcile it IS re-pushed on (re)connect.
if err := client.db.SaveConfig(&models.StoredConfig{
UUID: "gw-artifact-synced",
Kind: models.KindRestApi,
Expand Down Expand Up @@ -280,11 +282,19 @@ func TestPushGatewayArtifacts(t *testing.T) {

mu.Lock()
defer mu.Unlock()
if len(pushedID) != 1 {
t.Fatalf("pushed %d artifacts, want 1 (only the gateway-originated one): %v", len(pushedID), pushedID)
// Both gateway-origin artifacts are pushed (pending + already-synced); the CP-origin one is skipped.
if len(pushedID) != 2 {
t.Fatalf("pushed %d artifacts, want 2 (both gateway-originated): %v", len(pushedID), pushedID)
}
if pushedID[0] != "gw-artifact-1" {
t.Errorf("pushed artifact ID = %q, want gw-artifact-1", pushedID[0])
got := map[string]bool{}
for _, id := range pushedID {
got[id] = true
}
if !got["gw-artifact-1"] || !got["gw-artifact-synced"] {
t.Errorf("pushed IDs = %v, want both gw-artifact-1 and gw-artifact-synced", pushedID)
}
if got["cp-artifact-1"] {
t.Errorf("CP-origin artifact was pushed but should be skipped: %v", pushedID)
}
}

Expand Down
13 changes: 6 additions & 7 deletions gateway/gateway-controller/pkg/controlplane/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -631,18 +631,17 @@ func (c *Client) pushGatewayArtifacts() {
return
}

// Only artifacts whose control-plane sync is incomplete (cp_sync_status pending/failed)
// are pushed. This avoids re-pushing artifacts that have already been successfully synced.
configs, err := c.db.GetPendingCPSyncArtifacts()
// Push ALL gateway-origin artifacts.
configs, err := c.db.GetGatewayOriginArtifactsForSync()
if err != nil {
c.logger.Error("Failed to list pending gateway-originated artifacts for control plane push", slog.Any("error", err))
c.logger.Error("Failed to list gateway-originated artifacts for control plane push", slog.Any("error", err))
return
}
if len(configs) == 0 {
return
}

// GetPendingCPSyncArtifacts returns metadata only; load each full config to push.
// The query returns metadata only; load each full config to push.
full := make([]*models.StoredConfig, 0, len(configs))
for _, meta := range configs {
cfg, err := c.db.GetConfig(meta.UUID)
Expand All @@ -657,9 +656,9 @@ func (c *Client) pushGatewayArtifacts() {
return
}

c.logger.Info("Pushing pending gateway-originated artifacts to control plane", slog.Int("count", len(full)))
c.logger.Info("Pushing gateway-originated artifacts to control plane", slog.Int("count", len(full)))

// Push the whole pending set as a single ordered, zipped batch.
// Push the whole set as a single ordered, zipped batch.
resp, err := c.pushArtifactsWithRetry(full)
if err != nil {
// Transport-level failure: the artifacts keep their pending/failed cp_sync_status and
Expand Down
3 changes: 3 additions & 0 deletions gateway/gateway-controller/pkg/secrets/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ func (m *minimalStorage) GetPendingBottomUpAPIs() ([]*models.StoredConfig, error
func (m *minimalStorage) GetPendingCPSyncArtifacts() ([]*models.StoredConfig, error) {
return nil, nil
}
func (m *minimalStorage) GetGatewayOriginArtifactsForSync() ([]*models.StoredConfig, error) {
return nil, nil
}
func (m *minimalStorage) UpdateCPSyncStatus(uuid, cpArtifactID string, status models.CPSyncStatus, reason string) error {
return nil
}
Expand Down
4 changes: 4 additions & 0 deletions gateway/gateway-controller/pkg/storage/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ type Storage interface {
// is incomplete (cp_sync_status IN ('pending', 'failed')).
GetPendingCPSyncArtifacts() ([]*models.StoredConfig, error)

// GetGatewayOriginArtifactsForSync returns artifact metadata (Configuration nil) for every
// gateway-originated artifact across all artifacts-table kinds, regardless of cp_sync_status.
GetGatewayOriginArtifactsForSync() ([]*models.StoredConfig, error)

// ========================================
// LLM Provider Template Methods
// ========================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,32 @@ func TestSaveLLMProviderTemplate_DuplicateHandleConflicts(t *testing.T) {
_, err = store.GetConfig(dup.UUID)
assert.Assert(t, errors.Is(err, ErrNotFound))
}

// TestGetGatewayOriginArtifactsForSync_IncludesSuccess verifies the full-reconcile query returns
// ALL gateway-origin artifacts regardless of cp_sync_status — including already-"success" ones —
// whereas GetPendingCPSyncArtifacts excludes them. This is what lets a reconnect re-sync artifacts
// to a new/purged control plane (#2659).
func TestGetGatewayOriginArtifactsForSync_IncludesSuccess(t *testing.T) {
store := setupTestStorage(t)

// A synced (success) template and a not-yet-synced (pending) one.
synced := createTestLLMProviderTemplate()
pendingTmpl := createTestLLMProviderTemplate()
assert.NilError(t, store.SaveLLMProviderTemplate(synced))
assert.NilError(t, store.SaveLLMProviderTemplate(pendingTmpl))

// Mark the first as successfully synced.
assert.NilError(t, store.UpdateCPSyncStatus(synced.UUID, "cp-uuid-1", models.CPSyncStatusSuccess, ""))

// GetPendingCPSyncArtifacts excludes the success one.
pending, err := store.GetPendingCPSyncArtifacts()
assert.NilError(t, err)
assert.Assert(t, !pendingContains(pending, synced.UUID), "success artifact must NOT be pending")
assert.Assert(t, pendingContains(pending, pendingTmpl.UUID), "pending artifact must be pending")

// The full-reconcile query includes BOTH.
all, err := store.GetGatewayOriginArtifactsForSync()
assert.NilError(t, err)
assert.Assert(t, pendingContains(all, synced.UUID), "full reconcile must include the success artifact")
assert.Assert(t, pendingContains(all, pendingTmpl.UUID), "full reconcile must include the pending artifact")
}
21 changes: 21 additions & 0 deletions gateway/gateway-controller/pkg/storage/sql_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -1191,6 +1191,27 @@ func (s *sqlStore) GetPendingCPSyncArtifacts() ([]*models.StoredConfig, error) {
return scanArtifactMetadataRows(rows)
}

// GetGatewayOriginArtifactsForSync returns artifacts-table metadata for all gateway-originated
// artifacts regardless of cp_sync_status.
func (s *sqlStore) GetGatewayOriginArtifactsForSync() ([]*models.StoredConfig, error) {
query := `
SELECT uuid, kind, handle, display_name, version, data_version, desired_state,
deployment_id, origin, created_at, updated_at, deployed_at,
cp_sync_status, cp_sync_info, cp_artifact_id
FROM artifacts
WHERE gateway_id = ? AND origin = ?
ORDER BY created_at DESC
`

rows, err := s.query(query, s.gatewayId, string(models.OriginGatewayAPI))
if err != nil {
return nil, fmt.Errorf("failed to query gateway-origin artifacts for sync: %w", err)
}
defer rows.Close()

return scanArtifactMetadataRows(rows)
}

// scanArtifactMetadataRows scans rows from a query selecting the artifacts-table metadata
// columns (no resource-table JOIN, so Configuration is left nil): uuid, kind, handle,
// display_name, version, desired_state, deployment_id, origin, created_at, updated_at,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ func (m *MockStorage) GetPendingCPSyncArtifacts() ([]*models.StoredConfig, error
return nil, nil
}

func (m *MockStorage) GetGatewayOriginArtifactsForSync() ([]*models.StoredConfig, error) {
return nil, nil
}

// UpdateCPSyncStatus implements [storage.Storage].
func (m *MockStorage) UpdateCPSyncStatus(uuid, cpArtifactID string, status models.CPSyncStatus, reason string) error {
return nil
Expand Down
11 changes: 11 additions & 0 deletions gateway/gateway-controller/pkg/utils/api_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -966,8 +966,12 @@ type ImportArtifactRequest struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeployedAt *time.Time `json:"deployedAt,omitempty"`
Properties map[string]interface{} `json:"properties,omitempty"`
}

// deploymentRevisionProperty is the Properties key carrying the per-deployment revision.
const deploymentRevisionProperty = "deploymentRevision"

// ImportArtifactsResponse is the control plane's reply to the bulk DP->CP push: per-artifact
// results keyed by the artifact's data-plane UUID (dpid), plus aggregate counts.
type ImportArtifactsResponse struct {
Expand Down Expand Up @@ -1217,13 +1221,20 @@ func (s *APIUtilsService) buildImportArtifactRequest(artifact *models.StoredConf
utc := artifact.UpdatedAt.UTC()
deployedAt = &utc
}
var properties map[string]interface{}
if deployedAt != nil {
properties = map[string]interface{}{
deploymentRevisionProperty: deployedAt.Format(time.RFC3339Nano),
}
}
return ImportArtifactRequest{
DPID: artifact.UUID,
Configuration: configuration,
Status: string(artifact.DesiredState),
CreatedAt: artifact.CreatedAt.UTC(),
UpdatedAt: artifact.UpdatedAt.UTC(),
DeployedAt: deployedAt,
Properties: properties,
}, nil
}

Expand Down
27 changes: 27 additions & 0 deletions gateway/gateway-controller/pkg/utils/api_utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,33 @@ func TestAPIUtilsService_PushArtifact(t *testing.T) {
"template deployedAt should equal UpdatedAt; got %v want %v", got.DeployedAt, cfg.UpdatedAt.UTC())
})

t.Run("Deployable push carries a deploymentRevision derived from deployedAt", func(t *testing.T) {
// The control plane uses deploymentRevision to dedupe replay re-pushes (reconnect /
// full reconcile) so they do not create duplicate deployments. It is derived from the
// deployment time, so it is stable across re-pushes yet distinct across deployments.
var got ImportArtifactRequest
server := newHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqs := readPushedArtifacts(t, r)
require.Len(t, reqs, 1)
got = reqs[0]
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"total":1,"success":1,"failed":0,"artifacts":{"0000-test-api-0000-000000000000":{"id":"cp-rest-1","origin":"gateway_api","status":"deployed"}}}`))
}))
defer server.Close()

cfg := createTestStoredConfig("RestApi")
deployedAt := time.Date(2026, 5, 6, 7, 8, 9, 123456789, time.UTC)
cfg.DeployedAt = &deployedAt

svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, logger)
_, err := svc.PushArtifact(cfg.UUID, cfg, "")
require.NoError(t, err)

require.NotNil(t, got.Properties, "deployable push must carry Properties")
assert.Equal(t, deployedAt.Format(time.RFC3339Nano), got.Properties[deploymentRevisionProperty],
"deploymentRevision must equal the deployedAt in RFC3339Nano")
})

t.Run("HTTP error response", func(t *testing.T) {
server := newHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
Expand Down
10 changes: 10 additions & 0 deletions gateway/gateway-controller/pkg/utils/mock_db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,16 @@ func (m *testMockDB) GetPendingCPSyncArtifacts() ([]*models.StoredConfig, error)
return pending, nil
}

func (m *testMockDB) GetGatewayOriginArtifactsForSync() ([]*models.StoredConfig, error) {
var all []*models.StoredConfig
for _, config := range m.configs {
if config.Origin == models.OriginGatewayAPI {
all = append(all, config)
}
}
return all, nil
}

func (m *testMockDB) SaveWebhookSecret(secret *models.WebhookSecret) error { return nil }
func (m *testMockDB) GetWebhookSecretsByArtifact(artifactUUID string) ([]*models.WebhookSecret, error) {
return nil, nil
Expand Down
72 changes: 72 additions & 0 deletions platform-api/internal/repository/api_deployments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -603,3 +603,75 @@ func TestMain(m *testing.M) {
code := m.Run()
os.Exit(code)
}

// TestGetLatestDeploymentRevision verifies the per-gateway revision lookup used by the DP->CP
// import flow to dedupe replay re-pushes.
func TestGetLatestDeploymentRevision(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()

const (
orgUUID = "org-rev-001"
apiUUID = "api-rev-001"
gwUUID = "gw-rev-001"
)
createTestAPI(t, db, apiUUID, orgUUID)
createTestGateway(t, db, gwUUID, orgUUID)

repo := NewDeploymentRepo(db, NewArtifactTableRegistry())

// No deployment yet → empty revision.
rev, err := repo.GetLatestDeploymentRevision(apiUUID, gwUUID, orgUUID)
if err != nil {
t.Fatalf("GetLatestDeploymentRevision (none): %v", err)
}
if rev != "" {
t.Errorf("revision with no deployment = %q, want empty", rev)
}

deployed := model.DeploymentStatusDeployed
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

// One deployment carrying a revision.
if err := repo.CreateWithLimitEnforcement(&model.Deployment{
DeploymentID: "dep-rev-1",
Name: "dep-1",
ArtifactID: apiUUID,
GatewayID: gwUUID,
OrganizationID: orgUUID,
Content: []byte("c1"),
Status: &deployed,
CreatedAt: base,
Metadata: map[string]any{"gatewayRevision": "rev-A"},
}, 100); err != nil {
t.Fatalf("create dep-1: %v", err)
}
if rev, err = repo.GetLatestDeploymentRevision(apiUUID, gwUUID, orgUUID); err != nil || rev != "rev-A" {
t.Errorf("revision after dep-1 = %q (err %v), want rev-A", rev, err)
}

// A newer deployment with a different revision wins (latest by created_at).
if err := repo.CreateWithLimitEnforcement(&model.Deployment{
DeploymentID: "dep-rev-2",
Name: "dep-2",
ArtifactID: apiUUID,
GatewayID: gwUUID,
OrganizationID: orgUUID,
Content: []byte("c2"),
Status: &deployed,
CreatedAt: base.Add(time.Hour),
Metadata: map[string]any{"gatewayRevision": "rev-B"},
}, 100); err != nil {
t.Fatalf("create dep-2: %v", err)
}
if rev, err = repo.GetLatestDeploymentRevision(apiUUID, gwUUID, orgUUID); err != nil || rev != "rev-B" {
t.Errorf("revision after dep-2 = %q (err %v), want rev-B", rev, err)
}

// A different gateway has no deployment → empty (per-gateway scoping).
const gw2 = "gw-rev-002"
createTestGateway(t, db, gw2, orgUUID)
if rev, err = repo.GetLatestDeploymentRevision(apiUUID, gw2, orgUUID); err != nil || rev != "" {
t.Errorf("revision for other gateway = %q (err %v), want empty", rev, err)
}
}
Loading
Loading