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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId
import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus
import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore
import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository
import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository
import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository
import com.jorisjonkers.personalstack.assistant.infrastructure.k8s.Fabric8AgentRunnerOrchestrator
import io.fabric8.kubernetes.client.KubernetesClient
import io.fabric8.kubernetes.client.KubernetesClientException
Expand Down Expand Up @@ -379,12 +381,16 @@ class Fabric8AgentRunnerOrchestratorIntegrationTest {
client: KubernetesClient,
deployKeysProvider: ObjectProvider<DeployKeyStore>,
githubLinks: ObjectProvider<GithubLinkRepository>,
workspaceRepos: ObjectProvider<WorkspaceRepositoryRepository> = empty(),
repositories: ObjectProvider<RepositoryRepository> = empty(),
): Fabric8AgentRunnerOrchestrator =
Fabric8AgentRunnerOrchestrator(
client = client,
props = testProps(),
deployKeysProvider = deployKeysProvider,
githubLinks = githubLinks,
workspaceRepos = workspaceRepos,
repositories = repositories,
)

private fun testProps(): AgentRuntimeProperties =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.jorisjonkers.personalstack.assistant.persistence

import com.jorisjonkers.personalstack.assistant.IntegrationTestBase
import com.jorisjonkers.personalstack.assistant.domain.model.Repository
import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId
import com.jorisjonkers.personalstack.assistant.domain.model.Workspace
import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId
import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus
import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository
import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository
import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import java.time.Instant
import java.util.UUID

class JooqWorkspaceRepositoryRepositoryIntegrationTest : IntegrationTestBase() {
@Autowired
private lateinit var junction: WorkspaceRepositoryRepository

@Autowired
private lateinit var workspaces: WorkspaceRepository

@Autowired
private lateinit var repositories: RepositoryRepository

private fun workspace() =
Workspace(
id = WorkspaceId.random(),
name = "w",
repoUrl = "git@github.com:o/primary.git",
branch = "main",
podName = null,
pvcName = null,
gatewayEndpoint = null,
status = WorkspaceStatus.PENDING,
createdAt = Instant.now(),
updatedAt = Instant.now(),
).also(workspaces::save)

private fun repository() =
Repository(
id = RepositoryId.random(),
name = "r-${UUID.randomUUID()}",
repoUrl = "git@github.com:o/r.git",
defaultBranch = "main",
vaultKeyPath = "x",
deployKeyFingerprint = null,
deployKeyAddedAt = null,
createdAt = Instant.now(),
updatedAt = Instant.now(),
).also(repositories::save)

@Test
fun `attach inserts a row carried by findAllByWorkspaceId`() {
val w = workspace()
val r = repository()
junction.attach(w.id, r.id, isPrimary = true)

val links = junction.findAllByWorkspaceId(w.id)
assertThat(links).hasSize(1)
assertThat(links.single().repositoryId).isEqualTo(r.id)
assertThat(links.single().isPrimary).isTrue
}

@Test
fun `attach is idempotent on duplicate insert`() {
val w = workspace()
val r = repository()
junction.attach(w.id, r.id)
junction.attach(w.id, r.id)
assertThat(junction.findAllByWorkspaceId(w.id)).hasSize(1)
}

@Test
fun `detach removes the row`() {
val w = workspace()
val r = repository()
junction.attach(w.id, r.id)
junction.detach(w.id, r.id)
assertThat(junction.findAllByWorkspaceId(w.id)).isEmpty()
}

@Test
fun `findAllByWorkspaceId returns every attached repository`() {
val w = workspace()
val r1 = repository()
val r2 = repository()
junction.attach(w.id, r1.id, isPrimary = true)
junction.attach(w.id, r2.id)

val links = junction.findAllByWorkspaceId(w.id)
assertThat(links.map { it.repositoryId }).containsExactlyInAnyOrder(r1.id, r2.id)
assertThat(links.filter { it.isPrimary }.map { it.repositoryId }).containsExactly(r1.id)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.jorisjonkers.personalstack.assistant.application.command

import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId
import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId
import com.jorisjonkers.personalstack.common.command.Command

/**
* Attach an additional repository to a workspace. The repo is cloned
* alongside the primary (it becomes part of REPO_URLS) the next time the
* runner Pod boots; running pods are not re-provisioned.
*/
data class AttachWorkspaceRepositoryCommand(
val workspaceId: WorkspaceId,
val repositoryId: RepositoryId,
) : Command
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.jorisjonkers.personalstack.assistant.application.command

import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository
import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository
import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository
import com.jorisjonkers.personalstack.common.command.CommandHandler
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional

@Component
class AttachWorkspaceRepositoryCommandHandler(
private val workspaces: WorkspaceRepository,
private val repositories: RepositoryRepository,
private val links: WorkspaceRepositoryRepository,
) : CommandHandler<AttachWorkspaceRepositoryCommand> {
@Transactional
override fun handle(command: AttachWorkspaceRepositoryCommand) {
workspaces.findById(command.workspaceId)
?: throw NoSuchElementException("workspace not found: ${command.workspaceId}")
repositories.findById(command.repositoryId)
?: throw NoSuchElementException("repository not found: ${command.repositoryId}")
links.attach(command.workspaceId, command.repositoryId, isPrimary = false)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.jorisjonkers.personalstack.assistant.application.command

import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId
import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId
import com.jorisjonkers.personalstack.common.command.Command

/**
* Detach an additional repository from a workspace. The primary repository
* cannot be detached this way — it is owned by the workspace row itself.
*/
data class DetachWorkspaceRepositoryCommand(
val workspaceId: WorkspaceId,
val repositoryId: RepositoryId,
) : Command
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.jorisjonkers.personalstack.assistant.application.command

import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository
import com.jorisjonkers.personalstack.common.command.CommandHandler
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional

@Component
class DetachWorkspaceRepositoryCommandHandler(
private val links: WorkspaceRepositoryRepository,
) : CommandHandler<DetachWorkspaceRepositoryCommand> {
@Transactional
override fun handle(command: DetachWorkspaceRepositoryCommand) {
val link =
links
.findAllByWorkspaceId(command.workspaceId)
.firstOrNull { it.repositoryId == command.repositoryId }
require(link == null || !link.isPrimary) {
"cannot detach the primary repository from a workspace"
}
links.detach(command.workspaceId, command.repositoryId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.jorisjonkers.personalstack.assistant.domain.port

import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId
import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId
import java.time.Instant

/**
* Junction-table port for the workspace_repositories M:N mapping — the
* repositories a workspace clones beyond its primary. Operations are explicit
* (`attach` / `detach`) rather than save/find because the row carries no
* payload beyond the pair, the primary flag, and attachedAt.
*/
interface WorkspaceRepositoryRepository {
data class Link(
val workspaceId: WorkspaceId,
val repositoryId: RepositoryId,
val isPrimary: Boolean,
val attachedAt: Instant,
)

fun attach(
workspaceId: WorkspaceId,
repositoryId: RepositoryId,
isPrimary: Boolean = false,
): Link

fun detach(
workspaceId: WorkspaceId,
repositoryId: RepositoryId,
)

fun findAllByWorkspaceId(workspaceId: WorkspaceId): List<Link>
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import com.jorisjonkers.personalstack.assistant.domain.model.Workspace
import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator
import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore
import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository
import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository
import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository
import io.fabric8.kubernetes.api.model.ContainerPortBuilder
import io.fabric8.kubernetes.api.model.EnvVarBuilder
import io.fabric8.kubernetes.api.model.PersistentVolumeClaim
Expand Down Expand Up @@ -53,6 +55,8 @@ class Fabric8AgentRunnerOrchestrator(
private val props: AgentRuntimeProperties,
private val deployKeysProvider: ObjectProvider<DeployKeyStore>,
private val githubLinks: ObjectProvider<GithubLinkRepository>,
private val workspaceRepos: ObjectProvider<WorkspaceRepositoryRepository>,
private val repositories: ObjectProvider<RepositoryRepository>,
) : AgentRunnerOrchestrator {
private val log = LoggerFactory.getLogger(Fabric8AgentRunnerOrchestrator::class.java)

Expand Down Expand Up @@ -377,8 +381,25 @@ class Fabric8AgentRunnerOrchestrator(
add(EnvVarBuilder().withName("REPO_URL").withValue(url).build())
workspace.branch?.let { add(EnvVarBuilder().withName("REPO_BRANCH").withValue(it).build()) }
}
// REPO_URLS carries the workspace's additional repos (everything
// attached that is not the primary). The entrypoint clones each
// into its own subdir over the App-token credential helper. Only
// emitted when there are extras, so single-repo workspaces look
// identical to before to an older runner image.
additionalRepoUrls(workspace).takeIf { it.isNotEmpty() }?.let { urls ->
add(EnvVarBuilder().withName("REPO_URLS").withValue(urls.joinToString(" ")).build())
}
}

private fun additionalRepoUrls(workspace: Workspace): List<String> {
val links = workspaceRepos.ifAvailable ?: return emptyList()
val repos = repositories.ifAvailable ?: return emptyList()
return links
.findAllByWorkspaceId(workspace.id)
.filterNot { it.isPrimary }
.mapNotNull { repos.findById(it.repositoryId)?.repoUrl }
}

// KB_URL + KB_BEARER_TOKEN are the exact names the knowledge-system
// install.sh hooks read; without the bearer every hook short-circuits
// to a no-op and the knowledge.* MCP tools are unreachable.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.jorisjonkers.personalstack.assistant.infrastructure.persistence

import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId
import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId
import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository
import org.jooq.DSLContext
import org.jooq.Record
import org.jooq.impl.DSL
import org.springframework.stereotype.Repository
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID

@Repository
class JooqWorkspaceRepositoryRepository(
private val dsl: DSLContext,
) : WorkspaceRepositoryRepository {
override fun attach(
workspaceId: WorkspaceId,
repositoryId: RepositoryId,
isPrimary: Boolean,
): WorkspaceRepositoryRepository.Link {
val now = Instant.now()
dsl
.insertInto(JUNCTION)
.set(WORKSPACE_ID, workspaceId.value)
.set(REPOSITORY_ID, repositoryId.value)
.set(IS_PRIMARY, isPrimary)
.set(ATTACHED_AT, now.atOffset(ZoneOffset.UTC))
.onConflict(WORKSPACE_ID, REPOSITORY_ID)
.doNothing()
.execute()
return WorkspaceRepositoryRepository.Link(workspaceId, repositoryId, isPrimary, now)
}

override fun detach(
workspaceId: WorkspaceId,
repositoryId: RepositoryId,
) {
dsl
.deleteFrom(JUNCTION)
.where(WORKSPACE_ID.eq(workspaceId.value))
.and(REPOSITORY_ID.eq(repositoryId.value))
.execute()
}

override fun findAllByWorkspaceId(workspaceId: WorkspaceId): List<WorkspaceRepositoryRepository.Link> =
dsl
.selectFrom(JUNCTION)
.where(WORKSPACE_ID.eq(workspaceId.value))
.orderBy(ATTACHED_AT.asc())
.fetch()
.map { it.toLink() }

private fun Record.toLink(): WorkspaceRepositoryRepository.Link =
WorkspaceRepositoryRepository.Link(
workspaceId = WorkspaceId(this[WORKSPACE_ID]),
repositoryId = RepositoryId(this[REPOSITORY_ID]),
isPrimary = this[IS_PRIMARY],
attachedAt = this[ATTACHED_AT].toInstant(),
)

companion object {
@JvmStatic val JUNCTION = DSL.table("workspace_repositories")

@JvmStatic val WORKSPACE_ID = DSL.field("workspace_id", UUID::class.java)

@JvmStatic val REPOSITORY_ID = DSL.field("repository_id", UUID::class.java)

@JvmStatic val IS_PRIMARY = DSL.field("is_primary", Boolean::class.java)

@JvmStatic val ATTACHED_AT = DSL.field("attached_at", OffsetDateTime::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- A workspace can clone more than one repository. The primary repo stays
-- denormalised on workspaces (repository_id / repo_url) and drives REPO_URL;
-- this junction carries the additional repos that become REPO_URLS in the
-- runner Pod. is_primary marks the backfilled primary so the orchestrator can
-- exclude it from REPO_URLS without a second lookup.
CREATE TABLE workspace_repositories (
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
attached_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (workspace_id, repository_id)
);

CREATE INDEX idx_workspace_repositories_repository ON workspace_repositories (repository_id);

-- Backfill the primary repo for existing repo-backed workspaces so the model
-- is consistent from day one.
INSERT INTO workspace_repositories (workspace_id, repository_id, is_primary, attached_at)
SELECT id, repository_id, TRUE, created_at
FROM workspaces
WHERE repository_id IS NOT NULL;
Loading
Loading