[ARCHITECTURE] Multi-Module Gradle Restructure + Portainer Automation Service #124
Labels
No Label
architect:complete
blocked-by:#139
blocks:#140
bug
CI/CD
complexity:low
complexity:medium
complexity:medium
config
depends-on:#124
docker
docs
effort:l
effort:s
effort:xs
epic/development
lead:complete
needs-decision
performance
priority:critical
priority:high
priority:low
priority:medium
security
spike
story-points:1
story-points:3
story-points:5
story-points:8
tech-debt
test
user-story
architect:complete
complexity:high
complexity:low
complexity:medium
cross-repo
cross-repo-dev
dev:in-progress
effort:l
effort:m
effort:s
effort:xl
effort:xs
epic
analytics
epic
development
epic
devops
epic
infra
epic
observability
epic
platform
epic
product
lead:complete
needs-decision
pipeline-complete
pipeline-error
pipeline-running
priority
later
priority
next
priority
now
start-pipeline
status
acceptance
status
blocked
status
done
status
in progress
status
in review
status
in testing
status
ready
status
refine
status
triage
subtask
type
analysis
type
bug
type
hygiene
type
mantainence
type
story
user-story
No Milestone
No project
No Assignees
4 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: Hithomelabs/CFTunnels#124
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Architecture Design Proposal
Context
We're converting CFTunnels from a single-module Gradle project to a multi-module Gradle structure with three modules:
:cftunnels-service— existing Cloudflare Tunnel management API:portainer-automation— new Spring Boot service for Portainer redeployment:common— shared utilities (Portainer API client, models, HTTP config)Existing Infrastructure
192.168.0.100:8928(internal):9442:9443ci-runner:1.0.0with Docker socketProposed Module Structure
Gradle Build Configuration
settings.gradleRoot
build.gradlecommon/build.gradlecftunnels-service/build.gradleportainer-automation/build.gradleDockerfiles
cftunnels-service/Dockerfileportainer-automation/DockerfileArchitecture Decision: Deployment Trigger Mechanism
I see three viable options for how the Portainer Automation service receives triggers:
Option A: CI Workflow Calls Portainer Automation API (Recommended)
Flow:
CI workflow changes:
test_image_build_push.yml: After push, add curl step for devprod_image_tag_promote.yml: After tag/push, add curl step for prodPros:
Cons:
Option B: Gitea Webhook to Portainer Automation
Flow:
Pros:
Cons:
Option C: Hybrid — CI Calls API + Service Also Listens for Webhooks
Pros:
Cons:
Comparison
Security Considerations (from Hithomelabs Security Analysis)
Key Security Findings
1. Portainer CE Token Problem (CRITICAL)
Portainer CE has NO fine-grained RBAC. A single API key = full admin.
svc-portainer-devandsvc-portainer-produsers with their own API tokens2. Dual-Key Architecture
3. Network Segmentation
All traffic must stay on internal Docker networks. Automation service MUST NOT be exposed via Cloudflare Tunnel.
4. StackFileContent Round-Trip (File-Based Stacks)
File-based stacks require: GET file → PUT content back with pullImage:true. Compose content passes through automation in memory. Recommendation: Convert CFTunnels stacks to git-based stacks to use the simpler
/git/redeployendpoint which doesn't require content round-trip.5. CI Runner Privilege
Runner already has Docker socket. If compromised, attacker has Docker root. Automation API key is a secondary concern.
Additional Recommendations
Must-Do:
Should-Do:
5. HashiCorp Vault integration for Portainer key storage/rotation
6. Convert stacks from file-based to git-based (to use simpler /git/redeploy endpoint)
7. Different keys for CI auth vs Portainer auth (dual-key architecture)
Nice-to-Have:
8. Rate limiting on /api/deploy
9. HMAC signing for CI requests
10. Evaluate Portainer Business (free up to 5 nodes)
How CI Workflows Wire Up (Based on Option A)
test_image_build_push.yml(push totestbranch)prod_image_tag_promote.yml(push tomainbranch)test_build.yml(PR totest) — No changeintegration_test.yaml(push tomain) — No changePortainer Automation Service Design
Endpoints
/api/deploy/api/healthPOST
/api/deployRequestPOST
/api/deployResponse (200)Portainer API Interaction
The service will interact with Portainer CE 2.30.1 via:
POST /api/auth→ get JWTGET /api/stacks→ filter by Name fieldPUT /api/stacks/{id}?endpointId={eid}with{"pullImage":true, "prune":true}Portainer auth will use API access tokens (recommended over username/password JWT).
Configuration (
application.properties)Shared Components (
:commonmodule)PortainerAuthRequestPortainerAuthResponsePortainerStackPortainerRedeployRequestPortainerApiClientauthenticate(),findStack(name),redeploy(stackId, endpointId)ImageReference192.168.0.100:8928/hithomelabs/cftunnels:1.2.3into componentsRestTemplateConfigThe
RestTemplateConfigalready exists in the current codebase atcom.hithomelabs.CFTunnels.Config.RestTemplateConfig. This is a natural candidate to move to:commonso both services can use it.Questions for Decision
@hitanshu Please answer:
Which trigger mechanism do you prefer?
Portainer auth method?
POST /api/auth)Package naming:
com.hithomelabs.cftunnels(standard Java convention)com.hithomelabs.CFTunnels(backward compat)Portainer Automation deployment:
docker-compose.yamldocker-compose.portainer.yamlHow should Portainer Automation authenticate callers (the CI)?
X-API-Key)Separate Portainer users? Should we create
svc-portainer-devandsvc-portainer-produsers with their own API tokens?Git-based stacks? Are you willing to convert CFTunnels stacks from file-based to git-based to use the simpler
/git/redeployendpoint?Vault integration? Fetch Portainer keys from HashiCorp Vault at startup, or use plain env vars for now?
Internal-only? Confirm automation service stays off Cloudflare Tunnel?
Stack lookup method? Map stack names to IDs in config (as proposed), or look up by name at runtime via Portainer API?
Notification method? Service logs only, or also post deployment status to Gitea issue comments?
Decisions Confirmed ✅
Decisions from @hitanshu on 2026-07-05:
com.hithomelabs.cftunnels)docker-compose.yamlX-API-KeyheaderCRITICAL CONSTRAINT
All issues in this epic must NOT break existing CFTunnels integration/unit tests. Specifically:
test_build.yml(PR totest) — Gradle build & unit tests must continue to passintegration_test.yaml(push/schedule onmain) — Cloudflare API integration tests must continue to passtest_image_build_push.yml(push totest) — Docker image build & push must continue to workprod_image_tag_promote.yml(push tomain) — Image tag promotion must continue to workThe multi-module restructure must be done incrementally without breaking existing builds. The
test_build.ymlandintegration_test.yamlworkflows must not be modified (they don't need Portainer integration). Onlytest_image_build_push.ymlandprod_image_tag_promote.ymlget deploy steps added.Next
Pending @Polly's input on Portainer auth (question 2), then this issue can move to implementation planning.
🎯 Portainer Auth Analysis — Question 2 Answered
Hey @hitanshu, here's the full breakdown on Portainer auth for the
portainer-automationservice.Option-by-Option Evaluation
✅ Option 1: API Access Tokens (RECOMMENDED)
X-API-KeyheaderSpring Boot usage:
The service in practice:
PORTAINER_DEV_API_KEY/PORTAINER_PROD_API_KEYfrom envenvironmentfieldPUT /api/stacks/{id}?endpointId={eid}withX-API-KeyheaderPros:
curl -H "X-API-Key: ptk_..."Cons:
⚠️ Option 2: JWT via Username/Password
POST /api/auth { username, password }→ get JWT → useAuthorization: Bearer <jwt>The implementation overhead:
Plus an HTTP interceptor to detect 401 → refresh → retry.
Pros:
Cons:
🔶 Option 3: Separate Portainer Users
svc-portainer-dev+svc-portainer-produsers → generate tokens for eachThe hard truth about Portainer CE RBAC:
So separate users give you no security boundary in CE. They only help with:
🏆 My Recommendation: Option 1 + Separate Users (for audit)
The winning combo:
svc-portainer-devandsvc-portainer-prodPORTAINER_DEV_API_KEY→ptk_xxxx_dev_xxxxPORTAINER_PROD_API_KEY→ptk_xxxx_prod_xxxxX-API-Keyheader — no auth code neededWhy not just one shared token?
Why not pure JWT?
🤖 Can You Create API Tokens Programmatically?
Short answer: Partially yes, but practically no in CE.
Portainer does expose token management endpoints in the API:
POST/api/users/{id}/tokensGET/api/users/{id}/tokensDELETE/api/users/{id}/tokens/{tokenId}BUT the catch:
Practical workflow:
For automation rotation, you could later build a script that:
POST /api/users/{svcUserId}/tokensto generate new tokensBut for first launch, it's UI-only setup.
⚡ Implementation Impact on
portainer-automationWith Option 1, the
PortainerApiClientin:commonbecomes dead simple:No:
Just:
PortainerPropertiesX-API-KeyheaderLet me know if you want me to proceed with this plan or if you have any questions!
🏗️ Architecture Analysis: Portainer Connection & Environment Config
🔍 Root Cause Analysis: Why
192.168.0.100:9442FailedAfter examining the code, infrastructure patterns, and prior issues, here are all the likely causes:
1. Network Topology — The Core Issue (Most Likely)
192.168.0.100devdocker.hithomelabs.comThe hierarchy of likelihood:
192.168.0.100. If running from a different machine/laptop, this IP is unreachable without VPN/routing rules.devdocker.hithomelabs.comresolves to Cloudflare's edge → Tunnel → local Portainer. Direct IP access is intentionally blocked.192.168.0.100is on the same LAN but the Portainer container/service isn't listening on that interface/binding (only0.0.0.0:9442inside the container, mapped to Docker host's internal docker bridge).Evidence: The
cftunnels-serviceDB config inapplication.propertiesalso uses192.168.0.100:5432— if the developer's machine cannot reach this IP, then both DB and Portainer would fail. The fact that onlydevdocker.hithomelabs.comworked suggests the dev machine is off the local network and accessing services through Cloudflare Tunnels.2. Port 9442 vs 9443
From issue #124:
Portainer CE default is
9443. Using9442for dev is a deliberate custom mapping — verifying this is mapped correctly in Portainer's docker-compose is worth checking.3. SSL Certificate Mismatch
The code in
PortainerClientConfigalready trusts all certs (loadTrustMaterial((chain, authType) -> true)+NoopHostnameVerifier), so this shouldn't be the issue for either endpoint. The same trust-all config handles both IP and domain.4. CI/CD Implication
The CI runner runs in Docker on the same host as Portainer, so
192.168.0.100:9442will work in CI (the runner can reach the Docker host). Theapplication.propertiesdefault is fine for CI/deployed environments — only local dev needs the override.✅
devdocker.hithomelabs.com— Is This the Correct Long-Term Target?Short Answer: Yes, as the dev override, but NOT as the default.
Here's the recommended environment-specific config (following the pattern already established by
cftunnels-service):portainer.base-urlapplication.propertieshttps://192.168.0.100:9442localapplication-local.propertieshttps://devdocker.hithomelabs.comspring.profiles.active=local)prodapplication-prod.propertieshttps://192.168.0.100:9443Why
devdocker.hithomelabs.comis the right choice forlocal:Why NOT as default (
application.properties):The Missing Piece: No
application-prod.propertiesor CI profilesCurrently
portainer-automationonly has:application.properties(default)application-local.properties(dev override)Missing (for consistency with
cftunnels-service):application-prod.properties— prod Portainer URL on9443application-ci.properties— CI-specific overrides (if any)application-test.properties— test DB/stubs🧩 The Env Variables Array Issue
The developer had to add env var round-tripping (
DeployServicenow fetches stack env first, then passes them back on redeploy). This is correct and essential.Why it's needed:
PUT /api/stacks/{id}/git/redeploy?endpointId={eid}erases env vars if not included in the body${TAG},${HOST_PORT}) are lost on each redeploygetStack(stackId)→ extractenvlist → pass toredeployGitStack(stackId, ... , env)is the standard Portainer workflowOne thing to verify: The
EnvVariablemodel uses exact case-sensitive JSON property names (name,value). Portainer's API returns{"name": "...", "value": "..."}in the stack response and expects the same format back. This is already correct in the code.🎯 Architecture Recommendations
1. Add Environment-Specific Properties Files
Create these files:
portainer-automation/src/main/resources/application-prod.properties:portainer-automation/src/main/resources/application-ci.properties:Update
application-local.propertiesto match the developer's working config:Update
application.propertiesto reference env vars for secrets:2. Environment-Aware Config Resolution Order
This matches exactly what
cftunnels-servicealready does (spring.profiles.active=${ENV:default}).3. SSL Trust Configuration
The current
loadTrustMaterial((chain, authType) -> true)is fine for dev/CI but has security implications.Recommendation: Make SSL config environment-aware
For
localprofile — Trust-all (self-signed Portainer cert):For
prodprofile — Proper certificate validation:Use a
@Profile("prod")@Configurationthat loads the actual Portainer cert or trusts a CA chain instead of blanket trust-all.Simpler approach: If Portainer prod uses a proper certificate (e.g., Let's Encrypt via Cloudflare Tunnel), the default RestTemplate (without SSL customization) would work because Java trusts standard CAs. The trust-all config is only needed because
192.168.0.100:9443has a self-signed cert (default Portainer CE behavior).4. Naming Consistency
Consider renaming
portainer.api-keytoportainer.api-key(existing) andportainer.service.api-keytoportainer.auth.api-keyfor clarity:portainer.api-key→ key used to call Portainer APIportainer.service.api-key→ key used to authenticate callers of this serviceThe current
portainer.service.api-keyconfig key is ambiguous.⚙️ Summary of Actions
application-prod.properties,application-ci.propertiesDeployServiceportainer.service.api-key→portainer.auth.api-key.env.examplefor portainer-automation with documented env varsBottom line on @developer's question:
192.168.0.100:9442didn't work because the dev machine is likely off the local network and can only reach internal services through Cloudflare Tunnels. The default config is correct for CI/deployed environments. Theapplication-local.propertiesfix usingdevdocker.hithomelabs.comis the right solution, not a config bug — and the env variables array fix is a mandatory Portainer API requirement, not an architectural mistake.⏳ Architecture Status Check
Most decisions are confirmed ✅, but there's one remaining blocker:
Pending: Question #2 — Portainer Auth Method
@Polly provided a thorough analysis on July 5 recommending:
@hitanshu said on July 5:
Polly responded with the full breakdown. We need a ✅ or next steps.
What's Blocked on This Decision
:portainer-automationserviceCould we get a final call on this so we can close out the architecture phase? 🙏
Architectural Review: SSL Profile Fix for
PortainerClientConfigI've examined the full codebase — the
PortainerClientConfig.java, all four application-{profile}.properties files,PortainerAutomationProperties.java, andPortainerApiClient.java.✅ Root Cause Confirmed
localhttps://devdocker.hithomelabs.com(tunnel)prodhttps://192.168.0.100:9443https://192.168.0.100:9442(dev)cihttp://portainer-test:9000(HTTP)The
@Profile("!local")catch-all applies strict SSL to every non-local profile. Since both Portainer dev (:9442) and prod (:9443) use self-signed certificates, strict validation fails.⚠️ Issue: The Profile-Negation Approach Has a Blind Spot
The proposed
@Profile("!local & !prod")syntax is valid in Spring 6/Spring Boot 3 — it uses profile expression parsing.However, there's a latent bug: the default profile (when no
SPRING_PROFILES_ACTIVEis set) loadsapplication.properties→https://192.168.0.100:9442(self-signed) → strict RestTemplate → samebad_certificatefailure. Negation-based profiling requires updating the expression every time a new trust-all profile is added.🔧 Recommendation: Property-Driven SSL (Preferred)
Replace the
@Profile-based approach with a configuration property:Add to
PortainerAutomationProperties:Profile configs:
application.properties(dev default)portainer.trust-all-ssl=trueapplication-prod.propertiesportainer.trust-all-ssl=trueapplication-local.propertiesportainer.trust-all-ssl=trueapplication-ci.propertiesfalse)Benefits over profile-based approach:
false— accidental SSL trust is prevented)@Profilecoupling from RestTemplate configuration📋 Answers to Your Questions
Q1: Is the proposed approach sound?
It fixes the prod bug, but the negation pattern is fragile. The property-driven approach above is architecturally superior.
Q2: Edge cases with
@Profile("!local & !prod")?Profiles.of()parser supports&,!,|)prod,ci) → only the@Profile("prod")bean loads, no conflict ✅:9442❌Q3: Exception handling in the helper?
SSLContext.getInstance("TLS")can throwNoSuchAlgorithmExceptionandsslContext.init()can throwKeyManagementException. Best practice: catch these and wrap inIllegalStateExceptionwith clear messaging (shown above). The originalthrows Exceptionis acceptable but imprecise.Q4: Other concerns?
PoolingHttpClientConnectionManager. The strict path uses vanillaRestTemplate(no pooling). For prod, trust-all → pooling is a bonus. For CI (strict), no pooling is acceptable.NoopHostnameVerifier. This is required for internal Docker access (hitting192.168.0.100while the cert is for e.g.portainer.internal). Document this rationale in the code.architect:complete— profile config files exist. The propertyportainer.trust-all-sslcan be added to those files as an incremental change.Decision Required
@hitanshu Please choose:
@Profile("prod")with trust-all. Fixes prod immediately. Default profile remains broken.@Profilewithportainer.trust-all-sslproperty. Clean, extensible, no future maintenance.Please select (A/B) or describe your preference.