Skip to content

Commit 1f93973

Browse files
waveywavesclaude
andcommitted
feat(opentofu-agent): add OpenTofu Agent support
Add OpenTofu agent to Distr, enabling infrastructure provisioning alongside container deployments. Vendors can manage the full deployment lifecycle — applications and infrastructure — through a single workflow. - Database schema (migrations 84-85): opentofu deployment type, tofu columns on applicationversion/deploymentrevision, opentofu_state table for lock management - Agent manifest & connect: OpenTofu branch in connect flow - HTTP state backend: GET/POST state with S3 storage, POST lock/unlock with PostgreSQL locking, Basic Auth middleware - Agent binary (cmd/agent/opentofu): polling loop, reconciliation, OCI artifact pull, tofu init/plan/apply/destroy via terraform-exec SDK - Dockerfile: multi-stage build for the OpenTofu agent container - DEP document: full design proposal Closes #1946 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cf689d6 commit 1f93973

28 files changed

Lines changed: 2058 additions & 18 deletions

DEP-terraform-agent.md

Lines changed: 645 additions & 0 deletions
Large diffs are not rendered by default.

Dockerfile.opentofu-agent

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
FROM golang:1.26 AS builder
2+
ARG TARGETOS
3+
ARG TARGETARCH
4+
ARG VERSION
5+
ARG COMMIT
6+
7+
WORKDIR /workspace
8+
COPY go.mod go.mod
9+
COPY go.sum go.sum
10+
RUN go mod download
11+
12+
COPY api/ api/
13+
COPY cmd/agent/opentofu/ cmd/agent/opentofu/
14+
# doesn't exist (yet?)
15+
# COPY pkg/ pkg/
16+
COPY internal/ internal/
17+
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \
18+
go build -a -o agent \
19+
-ldflags="-s -w -X github.com/distr-sh/distr/internal/buildconfig.version=${VERSION:-snapshot} -X github.com/distr-sh/distr/internal/buildconfig.commit=${COMMIT}" \
20+
./cmd/agent/opentofu/
21+
22+
FROM alpine:3.21
23+
COPY --from=ghcr.io/opentofu/opentofu:1.9 /usr/local/bin/tofu /usr/local/bin/tofu
24+
WORKDIR /
25+
COPY --from=builder /workspace/agent .
26+
RUN addgroup -S agent && adduser -S agent -G agent && \
27+
mkdir -p /scratch && chown agent:agent /scratch
28+
USER agent
29+
ENV TF_IN_AUTOMATION=1
30+
ENV TF_INPUT=0
31+
ENV TF_PLUGIN_CACHE_DIR=/scratch/plugin-cache
32+
ENTRYPOINT ["/agent"]

api/agent.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ type AgentDeployment struct {
3939
Values map[string]any `json:"values"`
4040
IgnoreRevisionSkew bool `json:"ignoreRevisionSkew"`
4141
HelmOptions *HelmOptions `json:"helmOptions,omitempty"`
42+
43+
// OpenTofu specific data
44+
45+
TofuConfigURL string `json:"tofuConfigUrl,omitempty"`
46+
TofuConfigVersion string `json:"tofuConfigVersion,omitempty"`
47+
TofuVars map[string]any `json:"tofuVars,omitempty"`
48+
TofuBackendConfig map[string]string `json:"tofuBackendConfig,omitempty"`
49+
TofuVersion string `json:"tofuVersion,omitempty"`
4250
}
4351

4452
type AgentDeploymentStatus struct {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"os"
7+
"path"
8+
"sync"
9+
10+
"github.com/distr-sh/distr/api"
11+
"github.com/google/uuid"
12+
)
13+
14+
type State string
15+
16+
const (
17+
StateUnspecified State = ""
18+
StateInstalling State = "installing"
19+
StateInstalled State = "installed"
20+
StateFailed State = "failed"
21+
)
22+
23+
type AgentDeployment struct {
24+
ID uuid.UUID `json:"id"`
25+
RevisionID uuid.UUID `json:"revisionId"`
26+
TofuConfigURL string `json:"tofuConfigUrl"`
27+
TofuConfigVersion string `json:"tofuConfigVersion"`
28+
State State `json:"phase"`
29+
}
30+
31+
func (d AgentDeployment) GetDeploymentID() uuid.UUID {
32+
return d.ID
33+
}
34+
35+
func (d AgentDeployment) GetDeploymentRevisionID() uuid.UUID {
36+
return d.RevisionID
37+
}
38+
39+
func (d *AgentDeployment) FileName() string {
40+
return path.Join(DeploymentsDir(), d.ID.String())
41+
}
42+
43+
func NewAgentDeployment(deployment api.AgentDeployment) *AgentDeployment {
44+
return &AgentDeployment{
45+
ID: deployment.ID,
46+
RevisionID: deployment.RevisionID,
47+
TofuConfigURL: deployment.TofuConfigURL,
48+
TofuConfigVersion: deployment.TofuConfigVersion,
49+
}
50+
}
51+
52+
var agentDeploymentMutex = sync.RWMutex{}
53+
54+
func GetExistingDeployments() (map[uuid.UUID]AgentDeployment, error) {
55+
agentDeploymentMutex.RLock()
56+
defer agentDeploymentMutex.RUnlock()
57+
58+
if entries, err := os.ReadDir(DeploymentsDir()); err != nil {
59+
if errors.Is(err, os.ErrNotExist) {
60+
return nil, nil
61+
}
62+
return nil, err
63+
} else {
64+
fn := func(name string) (*AgentDeployment, error) {
65+
if file, err := os.Open(path.Join(DeploymentsDir(), name)); err != nil {
66+
return nil, err
67+
} else {
68+
defer file.Close()
69+
var d AgentDeployment
70+
if err := json.NewDecoder(file).Decode(&d); err != nil {
71+
return nil, err
72+
}
73+
return &d, nil
74+
}
75+
}
76+
result := make(map[uuid.UUID]AgentDeployment, len(entries))
77+
for _, entry := range entries {
78+
if !entry.IsDir() {
79+
if d, err := fn(entry.Name()); err != nil {
80+
return nil, err
81+
} else {
82+
result[d.ID] = *d
83+
}
84+
}
85+
}
86+
return result, nil
87+
}
88+
}
89+
90+
func SaveDeployment(deployment AgentDeployment) error {
91+
agentDeploymentMutex.Lock()
92+
defer agentDeploymentMutex.Unlock()
93+
94+
if err := os.MkdirAll(path.Dir(deployment.FileName()), 0o700); err != nil {
95+
return err
96+
}
97+
98+
file, err := os.Create(deployment.FileName())
99+
if err != nil {
100+
return err
101+
}
102+
defer file.Close()
103+
104+
if err := json.NewEncoder(file).Encode(deployment); err != nil {
105+
return err
106+
}
107+
108+
return nil
109+
}
110+
111+
func DeleteDeployment(deployment AgentDeployment) error {
112+
return os.Remove(deployment.FileName())
113+
}

cmd/agent/opentofu/config.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path"
6+
7+
"github.com/google/uuid"
8+
)
9+
10+
func ScratchDir() string {
11+
if dir := os.Getenv("DISTR_AGENT_SCRATCH_DIR"); dir != "" {
12+
return dir
13+
}
14+
return "./scratch"
15+
}
16+
17+
func WorkspaceDir(deploymentID uuid.UUID) string {
18+
return path.Join(ScratchDir(), "ws", deploymentID.String())
19+
}
20+
21+
func DeploymentsDir() string {
22+
return path.Join(ScratchDir(), "deployments")
23+
}

0 commit comments

Comments
 (0)