diff --git a/deploy/crd/giteas.glasskube.eu-v1.yml b/deploy/crd/giteas.glasskube.eu-v1.yml index 174eba97..b037c7f1 100644 --- a/deploy/crd/giteas.glasskube.eu-v1.yml +++ b/deploy/crd/giteas.glasskube.eu-v1.yml @@ -193,6 +193,24 @@ spec: required: - s3 type: object + actions: + properties: + enabled: + type: boolean + runners: + items: + properties: + token: + type: string + labels: + items: + type: string + type: array + required: + - token + type: object + type: array + type: object required: - host type: object diff --git a/operator/src/main/kotlin/eu/glasskube/kubernetes/api/model/apps/RollingUpdateStatefulSetStrategyDsl.kt b/operator/src/main/kotlin/eu/glasskube/kubernetes/api/model/apps/RollingUpdateStatefulSetStrategyDsl.kt index a748bb16..1f39d78f 100644 --- a/operator/src/main/kotlin/eu/glasskube/kubernetes/api/model/apps/RollingUpdateStatefulSetStrategyDsl.kt +++ b/operator/src/main/kotlin/eu/glasskube/kubernetes/api/model/apps/RollingUpdateStatefulSetStrategyDsl.kt @@ -1,9 +1,11 @@ package eu.glasskube.kubernetes.api.model.apps +import eu.glasskube.kubernetes.api.annotation.KubernetesDslMarker import eu.glasskube.kubernetes.api.model.intOrString import io.fabric8.kubernetes.api.model.apps.RollingUpdateStatefulSetStrategy import io.fabric8.kubernetes.api.model.apps.RollingUpdateStatefulSetStrategyBuilder +@KubernetesDslMarker class RollingUpdateStatefulSetStrategyDsl private constructor() { private val builder = RollingUpdateStatefulSetStrategyBuilder(true) diff --git a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/Gitea.kt b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/Gitea.kt index 692725fe..c4a8e299 100644 --- a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/Gitea.kt +++ b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/Gitea.kt @@ -49,6 +49,13 @@ class Gitea : override fun getDatabaseName(primary: Gitea) = "gitea" } + object Runner { + internal const val APP_NAME = "act-runner" + internal const val APP_VERSION = "0.2.6" + internal const val APP_IMAGE = "${Gitea.APP_NAME}/act_runner:$APP_VERSION" + internal const val DOCKER_IMAGE = "docker:23.0.6-dind" + } + @delegate:JsonIgnore override val velero by lazy { object : VeleroNameMapper(this) { @@ -59,6 +66,8 @@ class Gitea : } } +internal const val GITEA_RUNNER_LABEL = "glasskube.eu/gitea-runner" + val Gitea.resourceLabels get() = Labels.resourceLabels(Gitea.APP_NAME, metadata.name, Gitea.APP_NAME, spec.version) val Gitea.resourceLabelSelector @@ -71,3 +80,6 @@ val Gitea.iniConfigMapName get() = "$genericResourceName-ini" val Gitea.httpServiceName get() = "$genericResourceName-http" val Gitea.sshServiceName get() = "$genericResourceName-ssh" val Gitea.ingressTlsCertName get() = "$genericResourceName-cert" +fun Gitea.getRunnerName(runner: GiteaActionRunnerSpecTemplate) = "$genericResourceName-runner-${runner.tokenHash}" +val GiteaActionRunnerSpecTemplate.resourceLabels + get() = mapOf(Labels.COMPONENT to Gitea.Runner.APP_NAME, GITEA_RUNNER_LABEL to tokenHash) diff --git a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaActionRunnerSpecTemplate.kt b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaActionRunnerSpecTemplate.kt new file mode 100644 index 00000000..f89a92ae --- /dev/null +++ b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaActionRunnerSpecTemplate.kt @@ -0,0 +1,13 @@ +package eu.glasskube.operator.apps.gitea + +import eu.glasskube.utils.resourceHash +import io.fabric8.generator.annotation.Required + +data class GiteaActionRunnerSpecTemplate( + @field:Required + val token: String, + val labels: List? = null +) + +val GiteaActionRunnerSpecTemplate.tokenHash: String + get() = token.resourceHash diff --git a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaActionsSpec.kt b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaActionsSpec.kt new file mode 100644 index 00000000..b49fdc73 --- /dev/null +++ b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaActionsSpec.kt @@ -0,0 +1,6 @@ +package eu.glasskube.operator.apps.gitea + +data class GiteaActionsSpec( + val enabled: Boolean = false, + val runners: List = emptyList() +) diff --git a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaReconciler.kt b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaReconciler.kt index be992e2a..cacc7bf0 100644 --- a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaReconciler.kt +++ b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaReconciler.kt @@ -4,6 +4,8 @@ import eu.glasskube.kubernetes.client.patchOrUpdateStatus import eu.glasskube.operator.Labels import eu.glasskube.operator.api.reconciler.informerEventSource import eu.glasskube.operator.api.reconciler.secondaryResource +import eu.glasskube.operator.apps.gitea.dependent.GiteaActionRunnerSecrets +import eu.glasskube.operator.apps.gitea.dependent.GiteaActionRunnerStatefulSets import eu.glasskube.operator.apps.gitea.dependent.GiteaConfigMap import eu.glasskube.operator.apps.gitea.dependent.GiteaDeployment import eu.glasskube.operator.apps.gitea.dependent.GiteaHttpService @@ -85,7 +87,8 @@ import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers type = GiteaDeployment::class, name = "GiteaDeployment", dependsOn = ["GiteaPostgresCluster", "GiteaVolume", "GiteaSecret", "GiteaConfigMap", "GiteaIniConfigMap", "GiteaRedisService"], - useEventSourceWithName = GiteaReconciler.DEPLOYMENT_EVENT_SOURCE + useEventSourceWithName = GiteaReconciler.DEPLOYMENT_EVENT_SOURCE, + readyPostcondition = GiteaDeployment.ReadyCondition::class ), Dependent( type = GiteaHttpService::class, @@ -107,6 +110,16 @@ import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers name = "GiteaServiceMonitor", dependsOn = ["GiteaHttpService"] ), + Dependent( + type = GiteaActionRunnerStatefulSets::class, + name = "GiteaActionRunnerStatefulSets", + dependsOn = ["GiteaDeployment"] + ), + Dependent( + type = GiteaActionRunnerSecrets::class, + name = "GiteaActionRunnerSecrets", + dependsOn = ["GiteaDeployment"] + ), Dependent( type = GiteaVeleroSecret::class, name = "GiteaVeleroSecret", diff --git a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaSpec.kt b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaSpec.kt index bb30d85e..1001df1f 100644 --- a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaSpec.kt +++ b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/GiteaSpec.kt @@ -33,5 +33,6 @@ data class GiteaSpec( val version: String = "1.20.4", @field:Nullable override val database: PostgresDatabaseSpec = PostgresDatabaseSpec(), - override val backups: BackupSpec? + override val backups: BackupSpec?, + val actions: GiteaActionsSpec = GiteaActionsSpec() ) : HasBackupSpec, HasDatabaseSpec diff --git a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaActionRunnerSecrets.kt b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaActionRunnerSecrets.kt new file mode 100644 index 00000000..a63060c6 --- /dev/null +++ b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaActionRunnerSecrets.kt @@ -0,0 +1,49 @@ +package eu.glasskube.operator.apps.gitea.dependent + +import eu.glasskube.kubernetes.api.model.metadata +import eu.glasskube.kubernetes.api.model.namespace +import eu.glasskube.kubernetes.api.model.secret +import eu.glasskube.operator.apps.gitea.GITEA_RUNNER_LABEL +import eu.glasskube.operator.apps.gitea.Gitea +import eu.glasskube.operator.apps.gitea.GiteaReconciler +import eu.glasskube.operator.apps.gitea.getRunnerName +import eu.glasskube.operator.apps.gitea.resourceLabels +import eu.glasskube.utils.encodeBase64 +import io.fabric8.kubernetes.api.model.Secret +import io.javaoperatorsdk.operator.api.reconciler.Context +import io.javaoperatorsdk.operator.processing.dependent.BulkDependentResource +import io.javaoperatorsdk.operator.processing.dependent.Matcher +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent + +@KubernetesDependent(labelSelector = GiteaReconciler.SELECTOR) +class GiteaActionRunnerSecrets : + CRUDKubernetesDependentResource(Secret::class.java), BulkDependentResource { + override fun desiredResources(primary: Gitea, context: Context) = + primary.spec.actions.runners + .map { + secret { + metadata { + name(primary.getRunnerName(it)) + namespace(primary.namespace) + labels(primary.resourceLabels + it.resourceLabels) + } + data = mapOf("GITEA_RUNNER_REGISTRATION_TOKEN" to it.token.encodeBase64()) + } + } + .associateBy { it.metadata.name } + + override fun getSecondaryResources(primary: Gitea, context: Context) = + context.getSecondaryResources(Secret::class.java) + .filter { GITEA_RUNNER_LABEL in it.metadata.labels } + .associateBy { it.metadata.name } + .toMutableMap() + + override fun match( + actualResource: Secret, + desired: Secret, + primary: Gitea, + context: Context + ): Matcher.Result = + super.match(actualResource, desired, primary, context) +} diff --git a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaActionRunnerStatefulSets.kt b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaActionRunnerStatefulSets.kt new file mode 100644 index 00000000..6a715c71 --- /dev/null +++ b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaActionRunnerStatefulSets.kt @@ -0,0 +1,162 @@ +package eu.glasskube.operator.apps.gitea.dependent + +import eu.glasskube.kubernetes.api.model.apps.statefulSet +import eu.glasskube.kubernetes.api.model.container +import eu.glasskube.kubernetes.api.model.emptyDir +import eu.glasskube.kubernetes.api.model.env +import eu.glasskube.kubernetes.api.model.envFrom +import eu.glasskube.kubernetes.api.model.envVar +import eu.glasskube.kubernetes.api.model.limits +import eu.glasskube.kubernetes.api.model.metadata +import eu.glasskube.kubernetes.api.model.namespace +import eu.glasskube.kubernetes.api.model.requests +import eu.glasskube.kubernetes.api.model.resources +import eu.glasskube.kubernetes.api.model.secretRef +import eu.glasskube.kubernetes.api.model.securityContext +import eu.glasskube.kubernetes.api.model.spec +import eu.glasskube.kubernetes.api.model.volume +import eu.glasskube.kubernetes.api.model.volumeMount +import eu.glasskube.kubernetes.api.model.volumeMounts +import eu.glasskube.operator.apps.gitea.Gitea +import eu.glasskube.operator.apps.gitea.GiteaReconciler +import eu.glasskube.operator.apps.gitea.getRunnerName +import eu.glasskube.operator.apps.gitea.resourceLabelSelector +import eu.glasskube.operator.apps.gitea.resourceLabels +import io.fabric8.kubernetes.api.model.Quantity +import io.fabric8.kubernetes.api.model.apps.StatefulSet +import io.javaoperatorsdk.operator.api.reconciler.Context +import io.javaoperatorsdk.operator.processing.dependent.BulkDependentResource +import io.javaoperatorsdk.operator.processing.dependent.Matcher +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent + +@KubernetesDependent(labelSelector = GiteaReconciler.SELECTOR) +class GiteaActionRunnerStatefulSets : + CRUDKubernetesDependentResource(StatefulSet::class.java), BulkDependentResource { + override fun desiredResources(primary: Gitea, context: Context) = + primary.spec.actions.runners + .map { + statefulSet { + metadata { + name(primary.getRunnerName(it)) + namespace(primary.namespace) + labels(primary.resourceLabels + it.resourceLabels) + } + spec { + selector { matchLabels = primary.resourceLabelSelector + it.resourceLabels } + replicas(1) + updateStrategyRollingUpdate { + maxUnavailable("100%") + } + volumeClaimTemplates { + volumeClaimTemplate { + metadata { name(RUNNER_DATA_VOLUME) } + spec { + resources { requests = mapOf("storage" to Quantity("5", "Gi")) } + accessModes = listOf("ReadWriteOnce") + } + } + } + template { + metadata { labels(primary.resourceLabels + it.resourceLabels) } + spec { + volumes = listOf( + volume(DOCKER_CERT_VOLUME) { emptyDir() } + ) + containers = listOf( + container { + name = Gitea.Runner.APP_NAME + image = Gitea.Runner.APP_IMAGE + command = listOf("sh") + args = listOf( + "-c", + "while ! nc -z localhost 2376 ) = + context.getSecondaryResources(StatefulSet::class.java) + .associateBy { it.metadata.name } + .toMutableMap() + + override fun match( + actualResource: StatefulSet, + desired: StatefulSet, + primary: Gitea, + context: Context + ): Matcher.Result = + super.match(actualResource, desired, primary, context) + + companion object { + private const val DOCKER_HOST = "tcp://localhost:2376" + private const val DOCKER_CERT_PATH = "/certs" + private const val DOCKER_CLIENT_CERT_PATH = "$DOCKER_CERT_PATH/client" + private const val DOCKER_CERT_VOLUME = "docker-certs" + private const val RUNNER_DATA_PATH = "/data" + private const val RUNNER_DATA_VOLUME = "runner-data" + private const val DOCKER_DATA_PATH = "/var/lib/docker" + } +} diff --git a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaDeployment.kt b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaDeployment.kt index d6e44e2c..e781e35a 100644 --- a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaDeployment.kt +++ b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaDeployment.kt @@ -33,6 +33,7 @@ import eu.glasskube.operator.apps.gitea.resourceLabelSelector import eu.glasskube.operator.apps.gitea.resourceLabels import eu.glasskube.operator.apps.gitea.secretName import eu.glasskube.operator.config.ConfigService +import eu.glasskube.operator.generic.condition.DeploymentReadyCondition import eu.glasskube.utils.addTo import io.fabric8.kubernetes.api.model.HTTPGetAction import io.fabric8.kubernetes.api.model.Probe @@ -52,6 +53,7 @@ class GiteaDeployment(private val configService: ConfigService) : CRUDKubernetesDependentResource(Deployment::class.java) { internal class Discriminator : ResourceIDMatcherDiscriminator({ ResourceID(it.deploymentName, it.namespace) }) + internal class ReadyCondition : DeploymentReadyCondition() override fun desired(primary: Gitea, context: Context) = deployment { metadata { diff --git a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaIniConfigMap.kt b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaIniConfigMap.kt index faf6f573..b801eb17 100644 --- a/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaIniConfigMap.kt +++ b/operator/src/main/kotlin/eu/glasskube/operator/apps/gitea/dependent/GiteaIniConfigMap.kt @@ -34,7 +34,7 @@ class GiteaIniConfigMap : CRUDKubernetesDependentResource(Conf namespace(primary.namespace) labels(primary.resourceLabels) } - data = primary.baseConfig + getSmtpConfig(primary, context) + data = primary.baseConfig + getSmtpConfig(primary, context) + primary.actionsConfig } private val Gitea.baseConfig: Map @@ -87,6 +87,11 @@ class GiteaIniConfigMap : CRUDKubernetesDependentResource(Conf } } + private val Gitea.actionsConfig: Map + get() = mapOf( + "GITEA__actions__ENABLED" to spec.actions.enabled.toString() + ) + override fun onUpdated(primary: Gitea, updated: ConfigMap, actual: ConfigMap, context: Context) { super.onUpdated(primary, updated, actual, context) context.getSecondaryResource(GiteaDeployment.Discriminator()).ifPresent { diff --git a/operator/src/main/kotlin/eu/glasskube/operator/generic/condition/StatefulSetReadyCondition.kt b/operator/src/main/kotlin/eu/glasskube/operator/generic/condition/StatefulSetReadyCondition.kt new file mode 100644 index 00000000..faf00880 --- /dev/null +++ b/operator/src/main/kotlin/eu/glasskube/operator/generic/condition/StatefulSetReadyCondition.kt @@ -0,0 +1,29 @@ +package eu.glasskube.operator.generic.condition + +import io.fabric8.kubernetes.api.model.HasMetadata +import io.fabric8.kubernetes.api.model.apps.StatefulSet +import io.javaoperatorsdk.operator.api.reconciler.Context +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition +import kotlin.jvm.optionals.getOrDefault + +abstract class StatefulSetReadyCondition : Condition { + override fun isMet(dependentResource: DependentResource, primary: T, context: Context) = + dependentResource.getSecondaryResource(primary, context) + .map { it.isReady } + .getOrDefault(false) +} + +enum class StatefulSetState { + DOWN, DEGRADED, RUNNING +} + +val StatefulSet.state + get() = when (status?.readyReplicas) { + null, 0 -> StatefulSetState.DOWN + spec.replicas -> StatefulSetState.RUNNING + else -> StatefulSetState.DEGRADED + } + +val StatefulSet.isReady + get() = state == StatefulSetState.RUNNING