@@ -3,16 +3,46 @@ package com.ossuminc.sbt.helpers
33import com .typesafe .sbt .SbtNativePackager
44import com .typesafe .sbt .SbtNativePackager .{Docker , Universal }
55import com .typesafe .sbt .packager .docker .DockerPlugin
6+ import com .typesafe .sbt .packager .docker .DockerPlugin .autoImport ._
7+ import com .typesafe .sbt .packager .linux .LinuxPlugin .autoImport .daemonUser
68import com .typesafe .sbt .packager .graalvmnativeimage .GraalVMNativeImagePlugin
7- import com .typesafe .sbt .packager .Keys .*
9+ import com .typesafe .sbt .packager .Keys .{ maintainer , packageDescription , packageName , packageSummary , stage }
810import com .typesafe .sbt .packager .archetypes .JavaAppPackaging
911import com .typesafe .sbt .packager .graalvmnativeimage .GraalVMNativeImagePlugin .autoImport .graalVMNativeImageCommand
10- import sbt .*
12+ import sbt ._
13+ import sbt .Keys ._
1114
1215import java .io .File
1316
1417object Packaging extends AutoPluginHelper {
1518
19+ /** Keys for docker-dual configuration */
20+ object Keys {
21+ val dockerPublishProd = taskKey[Unit ](
22+ " Build and publish production Docker image (distroless, amd64)"
23+ )
24+ val dockerStageProd = taskKey[Unit ](
25+ " Stage production Docker image files"
26+ )
27+ val dockerProdBaseImage = settingKey[String ](
28+ " Base image for production Docker builds"
29+ )
30+ val dockerDevBaseImage = settingKey[String ](
31+ " Base image for development Docker builds"
32+ )
33+ val dockerMainClass = settingKey[String ](
34+ " Main class for Docker entrypoint"
35+ )
36+ }
37+
38+ /** Default base images for dual Docker builds */
39+ object Defaults {
40+ val devBaseImage = " eclipse-temurin:25-jdk-noble"
41+ val prodBaseImage = " gcr.io/distroless/java25-debian13:nonroot"
42+ val repository = " ghcr.io"
43+ val username = " ossuminc"
44+ }
45+
1646 override def apply (project : Project ) = none(project)
1747
1848 def none (project : Project ): Project = project
@@ -53,6 +83,129 @@ object Packaging extends AutoPluginHelper {
5383 )
5484 }
5585
86+ /** Configure dual Docker image builds for dev (local) and prod (GKE).
87+ *
88+ * Dev image:
89+ * - Base: eclipse-temurin:25-jdk-noble (Ubuntu 24.04 with JDK tools)
90+ * - Architecture: linux/arm64 (Apple Silicon)
91+ * - Tags: :dev-latest, :dev-<version>
92+ * - Built with: docker:publishLocal (default)
93+ *
94+ * Prod image:
95+ * - Base: gcr.io/distroless/java25-debian13:nonroot (minimal, secure)
96+ * - Architecture: linux/amd64 (GKE)
97+ * - Tags: :latest, :<version>
98+ * - Built with: dockerPublishProd
99+ *
100+ * @param mainClass
101+ * Fully qualified main class name (e.g., "com.ossuminc.riddl.mcp.Main")
102+ * @param pkgName
103+ * Docker image name (e.g., "riddl-mcp-server")
104+ * @param exposedPorts
105+ * Ports to expose in the container
106+ * @param pkgDescription
107+ * Optional description for the package
108+ */
109+ def dockerDual (
110+ mainClass : String ,
111+ pkgName : String ,
112+ exposedPorts : Seq [Int ],
113+ pkgDescription : String = " "
114+ )(project : Project ): Project = {
115+ project
116+ .enablePlugins(SbtNativePackager , JavaAppPackaging , DockerPlugin )
117+ .settings(
118+ // Store configuration for use in tasks
119+ Keys .dockerMainClass := mainClass,
120+ Keys .dockerDevBaseImage := Defaults .devBaseImage,
121+ Keys .dockerProdBaseImage := Defaults .prodBaseImage,
122+
123+ // Default Docker settings (dev image - what docker:publishLocal uses)
124+ dockerBaseImage := Keys .dockerDevBaseImage.value,
125+ dockerRepository := Some (Defaults .repository),
126+ dockerUsername := Some (Defaults .username),
127+ Docker / packageName := pkgName,
128+ Docker / packageDescription := pkgDescription,
129+ Docker / daemonUser := " ossum" ,
130+ dockerExposedPorts := exposedPorts,
131+
132+ // Dev image tags: dev-latest, dev-<version>
133+ dockerAliases := Seq (
134+ dockerAlias.value.withTag(Some (s " dev- ${version.value}" )),
135+ dockerAlias.value.withTag(Some (" dev-latest" ))
136+ ),
137+
138+ // Note: docker:publishLocal uses standard Docker build which defaults to host
139+ // architecture (arm64 on Apple Silicon, amd64 on Intel/Linux). This is ideal
140+ // for local development. Production builds use buildx with explicit amd64.
141+
142+ // Production staging task - creates Dockerfile for distroless
143+ Keys .dockerStageProd := {
144+ val log = streams.value.log
145+ val stageDir = (Docker / stagingDirectory).value
146+ val mainCls = Keys .dockerMainClass.value
147+ val prodBase = Keys .dockerProdBaseImage.value
148+ val name = (Docker / packageName).value
149+ val ver = version.value
150+ val ports = dockerExposedPorts.value
151+
152+ log.info(s " Staging production Docker image for $name: $ver" )
153+
154+ // First run the normal staging to get lib/ directory
155+ (Docker / stage).value
156+
157+ // Generate distroless Dockerfile with EXPOSE directives
158+ val dockerfile = stageDir / " Dockerfile"
159+ val exposeLines = ports.map(p => s " EXPOSE $p" ).mkString(" \n " )
160+ val dockerfileContent =
161+ s """ FROM $prodBase
162+ |WORKDIR /opt/docker
163+ |COPY --chown=nonroot:nonroot opt/docker/lib lib
164+ | $exposeLines
165+ |ENTRYPOINT ["java", "-cp", "/opt/docker/lib/*", " $mainCls"]
166+ | """ .stripMargin
167+
168+ IO .write(dockerfile, dockerfileContent)
169+ log.info(s " Generated distroless Dockerfile at $dockerfile" )
170+ },
171+
172+ // Production publish task
173+ Keys .dockerPublishProd := {
174+ val log = streams.value.log
175+ val stageDir = (Docker / stagingDirectory).value
176+ val name = (Docker / packageName).value
177+ val ver = version.value
178+ val repo = dockerRepository.value.getOrElse(Defaults .repository)
179+ val user = dockerUsername.value.getOrElse(Defaults .username)
180+
181+ // Run staging first
182+ Keys .dockerStageProd.value
183+
184+ val fullImageName = s " $repo/ $user/ $name"
185+ val tags = Seq (ver, " latest" )
186+
187+ log.info(s " Building production image: $fullImageName" )
188+
189+ // Build with buildx for amd64
190+ val tagArgs = tags.flatMap(t => Seq (" -t" , s " $fullImageName: $t" ))
191+ val buildCmd = Seq (
192+ " docker" , " buildx" , " build" ,
193+ " --platform" , " linux/amd64" ,
194+ " --push"
195+ ) ++ tagArgs ++ Seq (stageDir.getAbsolutePath)
196+
197+ log.info(s " Running: ${buildCmd.mkString(" " )}" )
198+
199+ val exitCode = sys.process.Process (buildCmd).!
200+ if (exitCode != 0 ) {
201+ sys.error(s " Docker buildx failed with exit code $exitCode" )
202+ }
203+
204+ log.info(s " Successfully published $fullImageName: $ver and $fullImageName:latest " )
205+ }
206+ )
207+ }
208+
56209 def graalVM (pkgName : String , pkgSummary : String , native_image_path : File )(
57210 project : Project
58211 ): Project = {
0 commit comments