Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions .github/workflows/build-agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
agent:
- docker-agent
- kubernetes-agent
- opentofu-agent
platform:
- arch: linux/amd64
runner: ubuntu-latest
Expand Down Expand Up @@ -108,6 +109,7 @@ jobs:
agent:
- docker-agent
- kubernetes-agent
- opentofu-agent
env:
REGISTRY_IMAGE: ghcr.io/distr-sh/distr/${{ matrix.agent }}
steps:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test-agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jobs:
agent:
- docker
- kubernetes
- opentofu
permissions:
contents: read
steps:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
/out-tsc
/bazel-out
hub
opentofu

# Node
/node_modules
Expand Down
32 changes: 32 additions & 0 deletions Dockerfile.opentofu-agent
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
FROM golang:1.26 AS builder
ARG TARGETOS
ARG TARGETARCH
ARG VERSION
ARG COMMIT

WORKDIR /workspace
COPY go.mod go.mod
COPY go.sum go.sum
RUN go mod download

COPY api/ api/
COPY cmd/agent/opentofu/ cmd/agent/opentofu/
# doesn't exist (yet?)
# COPY pkg/ pkg/
COPY internal/ internal/
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \
go build -a -o agent \
-ldflags="-s -w -X github.com/distr-sh/distr/internal/buildconfig.version=${VERSION:-snapshot} -X github.com/distr-sh/distr/internal/buildconfig.commit=${COMMIT}" \
./cmd/agent/opentofu/

FROM alpine:3.21
COPY --from=ghcr.io/opentofu/opentofu:1.9 /usr/local/bin/tofu /usr/local/bin/tofu
WORKDIR /
COPY --from=builder /workspace/agent .
RUN addgroup -S agent && adduser -S agent -G agent && \
mkdir -p /scratch && chown agent:agent /scratch
USER agent
ENV TF_IN_AUTOMATION=1
ENV TF_INPUT=0
ENV TF_PLUGIN_CACHE_DIR=/scratch/plugin-cache
ENTRYPOINT ["/agent"]
7 changes: 7 additions & 0 deletions api/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ type AgentDeployment struct {
Values map[string]any `json:"values"`
IgnoreRevisionSkew bool `json:"ignoreRevisionSkew"`
HelmOptions *HelmOptions `json:"helmOptions,omitempty"`

// OpenTofu specific data

TofuConfigURL string `json:"tofuConfigUrl,omitempty"`
TofuConfigVersion string `json:"tofuConfigVersion,omitempty"`
TofuVars map[string]any `json:"tofuVars,omitempty"`
TofuBackendConfig map[string]string `json:"tofuBackendConfig,omitempty"`
}

type AgentDeploymentStatus struct {
Expand Down
2 changes: 2 additions & 0 deletions api/deployment_target.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type DeploymentRequest struct {
ForceRestart bool `json:"forceRestart"`
IgnoreRevisionSkew bool `json:"ignoreRevisionSkew"`
HelmOptions *HelmOptions `json:"helmOptions,omitempty"`
TofuVars map[string]any `json:"tofuVars,omitempty"`
TofuBackendConfig map[string]string `json:"tofuBackendConfig,omitempty"`
}

func (d *DeploymentRequest) GetValuesYAML() []byte {
Expand Down
1 change: 1 addition & 0 deletions api/organization.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type CreateUpdateOrganizationRequest struct {
ConnectScriptIsSudo bool `json:"connectScriptIsSudo"`
ArtifactVersionMutable bool `json:"artifactVersionMutable"`
PrePostScriptsEnabled bool `json:"prePostScriptsEnabled"`
OpenTofuEnabled bool `json:"openTofuEnabled"`

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OpenTofuEnabled is added as a non-pointer bool on the create/update request. For update endpoints this is a backward-compatibility footgun: older clients that don’t send the new field will decode it as false and can unintentionally disable the feature. Consider making this field a *bool (apply only when non-nil) or separating create vs update request shapes.

Suggested change
OpenTofuEnabled bool `json:"openTofuEnabled"`
OpenTofuEnabled *bool `json:"openTofuEnabled"`

Copilot uses AI. Check for mistakes.
}

type OrganizationResponse struct {
Expand Down
117 changes: 117 additions & 0 deletions cmd/agent/opentofu/agent_deployment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package main

import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"

"github.com/distr-sh/distr/api"
"github.com/google/uuid"
)

type State string

const (
StateUnspecified State = ""
StateInstalling State = "installing"
StateInstalled State = "installed"
StateFailed State = "failed"
)

type AgentDeployment struct {
ID uuid.UUID `json:"id"`
RevisionID uuid.UUID `json:"revisionId"`
TofuConfigURL string `json:"tofuConfigUrl"`
TofuConfigVersion string `json:"tofuConfigVersion"`
TofuBackendConfig map[string]string `json:"tofuBackendConfig,omitempty"`
State State `json:"phase"`
}

Comment on lines +29 to +31

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The local deployment state serializes State under JSON key phase, which is inconsistent with the field name and the rest of the agent/Hub API terminology (state is used elsewhere). Renaming the JSON tag (and handling backward-compat if needed) would make local state files easier to reason about and reduce confusion.

Suggested change
State State `json:"phase"`
}
State State `json:"state"`
}
type agentDeploymentJSON struct {
ID uuid.UUID `json:"id"`
RevisionID uuid.UUID `json:"revisionId"`
TofuConfigURL string `json:"tofuConfigUrl"`
TofuConfigVersion string `json:"tofuConfigVersion"`
TofuVersion string `json:"tofuVersion,omitempty"`
TofuBackendConfig map[string]string `json:"tofuBackendConfig,omitempty"`
State State `json:"state,omitempty"`
Phase State `json:"phase,omitempty"`
}
func (d AgentDeployment) MarshalJSON() ([]byte, error) {
aux := agentDeploymentJSON{
ID: d.ID,
RevisionID: d.RevisionID,
TofuConfigURL: d.TofuConfigURL,
TofuConfigVersion: d.TofuConfigVersion,
TofuVersion: d.TofuVersion,
TofuBackendConfig: d.TofuBackendConfig,
State: d.State,
}
return json.Marshal(aux)
}
func (d *AgentDeployment) UnmarshalJSON(data []byte) error {
var aux agentDeploymentJSON
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
d.ID = aux.ID
d.RevisionID = aux.RevisionID
d.TofuConfigURL = aux.TofuConfigURL
d.TofuConfigVersion = aux.TofuConfigVersion
d.TofuVersion = aux.TofuVersion
d.TofuBackendConfig = aux.TofuBackendConfig
if aux.State != StateUnspecified {
d.State = aux.State
} else {
d.State = aux.Phase
}
return nil
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This follows the existing Docker agent pattern which also uses json:"phase" for the State field. Keeping it consistent across agents.

func (d AgentDeployment) GetDeploymentID() uuid.UUID {
return d.ID
}

func (d AgentDeployment) GetDeploymentRevisionID() uuid.UUID {
return d.RevisionID
}

func (d *AgentDeployment) FileName() string {
return filepath.Join(DeploymentsDir(), d.ID.String())
}
Comment thread
waveywaves marked this conversation as resolved.

func NewAgentDeployment(deployment api.AgentDeployment) *AgentDeployment {
return &AgentDeployment{
ID: deployment.ID,
RevisionID: deployment.RevisionID,
TofuConfigURL: deployment.TofuConfigURL,
TofuConfigVersion: deployment.TofuConfigVersion,
TofuBackendConfig: deployment.TofuBackendConfig,
}
}

var agentDeploymentMutex = sync.RWMutex{}

func GetExistingDeployments() (map[uuid.UUID]AgentDeployment, error) {
agentDeploymentMutex.RLock()
defer agentDeploymentMutex.RUnlock()

if entries, err := os.ReadDir(DeploymentsDir()); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
} else {
fn := func(name string) (*AgentDeployment, error) {
if file, err := os.Open(filepath.Join(DeploymentsDir(), name)); err != nil {
return nil, err
} else {
defer file.Close()
var d AgentDeployment
if err := json.NewDecoder(file).Decode(&d); err != nil {
return nil, err
}
return &d, nil
}
}
result := make(map[uuid.UUID]AgentDeployment, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
if d, err := fn(entry.Name()); err != nil {
return nil, err
} else {
result[d.ID] = *d
}
}
}
return result, nil
}
}

func SaveDeployment(deployment AgentDeployment) error {
agentDeploymentMutex.Lock()
defer agentDeploymentMutex.Unlock()

if err := os.MkdirAll(filepath.Dir(deployment.FileName()), 0o700); err != nil {
return err
}

file, err := os.OpenFile(deployment.FileName(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer file.Close()

if err := json.NewEncoder(file).Encode(deployment); err != nil {
return err
}

return nil
}

func DeleteDeployment(deployment AgentDeployment) error {
agentDeploymentMutex.Lock()
defer agentDeploymentMutex.Unlock()
return os.Remove(deployment.FileName())
}
23 changes: 23 additions & 0 deletions cmd/agent/opentofu/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package main

import (
"os"
"path/filepath"

"github.com/google/uuid"
)

func ScratchDir() string {
if dir := os.Getenv("DISTR_AGENT_SCRATCH_DIR"); dir != "" {
return dir
}
return "./scratch"
}

func WorkspaceDir(deploymentID uuid.UUID) string {
return filepath.Join(ScratchDir(), "ws", deploymentID.String())
}

func DeploymentsDir() string {
return filepath.Join(ScratchDir(), "deployments")
}
Loading