diff --git a/cmd/agent/agent.go b/cmd/agent/agent.go index e57447d25..89a35d850 100644 --- a/cmd/agent/agent.go +++ b/cmd/agent/agent.go @@ -23,6 +23,7 @@ import ( "net/http" "os" "runtime" + "strconv" "strings" "sync" "time" @@ -49,6 +50,7 @@ import ( ) func run(c *cli.Context) error { + agentIDConfigPath := c.String("agent-id-config-path") hostname := c.String("hostname") if len(hostname) == 0 { hostname, _ = os.Hostname() @@ -109,7 +111,7 @@ func run(c *cli.Context) error { } defer authConn.Close() - agentID := int64(-1) // TODO: store agent id in a file + agentID := readAgentID(agentIDConfigPath) agentToken := c.String("grpc-token") authClient := agentRpc.NewAuthGrpcClient(authConn, agentToken, agentID) authInterceptor, err := agentRpc.NewAuthInterceptor(authClient, 30*time.Minute) @@ -178,6 +180,8 @@ func run(c *cli.Context) error { return err } + writeAgentID(agentID, agentIDConfigPath) + labels := map[string]string{ "hostname": hostname, "platform": platform, @@ -280,3 +284,33 @@ func stringSliceAddToMap(sl []string, m map[string]string) error { } return nil } + +func readAgentID(agentIDConfigPath string) int64 { + const defaultAgentIDValue = int64(-1) + + rawAgentID, fileErr := os.ReadFile(agentIDConfigPath) + if fileErr != nil { + log.Debug().Err(fileErr).Msgf("could not open agent-id config file from %s", agentIDConfigPath) + return defaultAgentIDValue + } + + strAgentID := strings.TrimSpace(string(rawAgentID)) + agentID, parseErr := strconv.ParseInt(strAgentID, 10, 64) + if parseErr != nil { + log.Warn().Err(parseErr).Msg("could not parse agent-id config file content to int64") + return defaultAgentIDValue + } + + return agentID +} + +func writeAgentID(agentID int64, agentIDConfigPath string) { + currentAgentID := readAgentID(agentIDConfigPath) + + if currentAgentID != agentID { + err := os.WriteFile(agentIDConfigPath, []byte(strconv.FormatInt(agentID, 10)+"\n"), 0o644) + if err != nil { + log.Warn().Err(err).Msgf("could not write agent-id config file to %s", agentIDConfigPath) + } + } +} diff --git a/cmd/agent/agent_test.go b/cmd/agent/agent_test.go index 1e645e68d..ded229ed6 100644 --- a/cmd/agent/agent_test.go +++ b/cmd/agent/agent_test.go @@ -15,6 +15,8 @@ package main import ( + "fmt" + "os" "testing" "github.com/stretchr/testify/assert" @@ -73,3 +75,91 @@ func TestStringSliceAddToMap(t *testing.T) { }) } } + +func TestReadAgentIDFileNotExists(t *testing.T) { + assert.EqualValues(t, -1, readAgentID("foobar.conf")) +} + +func TestReadAgentIDFileExists(t *testing.T) { + parameters := []struct { + input string + expected int64 + }{ + {"42", 42}, + {"42\n", 42}, + {" \t42\t\r\t", 42}, + {"0", 0}, + {"-1", -1}, + {"foo", -1}, + {"1f", -1}, + {"", -1}, + {"-42", -42}, + } + + for i := range parameters { + t.Run(fmt.Sprintf("Testing [%v]", i), func(t *testing.T) { + tmpF, errTmpF := os.CreateTemp("", "tmp_") + if !assert.NoError(t, errTmpF) { + t.FailNow() + } + + errWrite := os.WriteFile(tmpF.Name(), []byte(parameters[i].input), 0o644) + if !assert.NoError(t, errWrite) { + t.FailNow() + } + + actual := readAgentID(tmpF.Name()) + assert.EqualValues(t, parameters[i].expected, actual) + }) + } +} + +func TestWriteAgentIDFileNotExists(t *testing.T) { + tmpF, errTmpF := os.CreateTemp("", "tmp_") + if !assert.NoError(t, errTmpF) { + t.FailNow() + } + + writeAgentID(42, tmpF.Name()) + actual, errRead := os.ReadFile(tmpF.Name()) + if !assert.NoError(t, errRead) { + t.FailNow() + } + assert.EqualValues(t, "42\n", actual) +} + +func TestWriteAgentIDFileExists(t *testing.T) { + parameters := []struct { + fileInput string + writeInput int64 + expected string + }{ + {"", 42, "42\n"}, + {"\n", 42, "42\n"}, + {"41\n", 42, "42\n"}, + {"0", 42, "42\n"}, + {"-1", 42, "42\n"}, + {"foƶbar", 42, "42\n"}, + } + + for i := range parameters { + t.Run(fmt.Sprintf("Testing [%v]", i), func(t *testing.T) { + tmpF, errTmpF := os.CreateTemp("", "tmp_") + if !assert.NoError(t, errTmpF) { + t.FailNow() + } + + errWrite := os.WriteFile(tmpF.Name(), []byte(parameters[i].fileInput), 0o644) + if !assert.NoError(t, errWrite) { + t.FailNow() + } + + writeAgentID(parameters[i].writeInput, tmpF.Name()) + actual, errRead := os.ReadFile(tmpF.Name()) + if !assert.NoError(t, errRead) { + t.FailNow() + } + assert.EqualValues(t, parameters[i].expected, actual) + }) + } +} diff --git a/cmd/agent/flags.go b/cmd/agent/flags.go index 8a191b791..5b6b09c52 100644 --- a/cmd/agent/flags.go +++ b/cmd/agent/flags.go @@ -67,6 +67,12 @@ var flags = []cli.Flag{ Name: "hostname", Usage: "agent hostname", }, + &cli.StringFlag{ + EnvVars: []string{"WOODPECKER_AGENT_ID_FILE"}, + Name: "agent-id-config-path", + Usage: "agent-id config file path", + Value: "/etc/woodpecker/agent-id.conf", + }, &cli.StringSliceFlag{ EnvVars: []string{"WOODPECKER_FILTER_LABELS"}, Name: "filter", diff --git a/docker/Dockerfile.agent.alpine.multiarch b/docker/Dockerfile.agent.alpine.multiarch index 4e88c0604..d4fba3e66 100644 --- a/docker/Dockerfile.agent.alpine.multiarch +++ b/docker/Dockerfile.agent.alpine.multiarch @@ -13,6 +13,7 @@ ENV GODEBUG=netdns=go EXPOSE 3000 COPY --from=build /src/dist/woodpecker-agent /bin/ +RUN mkdir -p /etc/woodpecker HEALTHCHECK CMD ["/bin/woodpecker-agent", "ping"] ENTRYPOINT ["/bin/woodpecker-agent"] diff --git a/docker/Dockerfile.agent.multiarch b/docker/Dockerfile.agent.multiarch index 93fb8b4e6..6bcf5b766 100644 --- a/docker/Dockerfile.agent.multiarch +++ b/docker/Dockerfile.agent.multiarch @@ -6,6 +6,7 @@ ARG TARGETOS TARGETARCH RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg \ make build-agent +RUN mkdir -p /etc/woodpecker FROM scratch ENV GODEBUG=netdns=go @@ -15,6 +16,7 @@ EXPOSE 3000 COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt # copy agent binary COPY --from=build /src/dist/woodpecker-agent /bin/ +COPY --from=build /etc/woodpecker /etc HEALTHCHECK CMD ["/bin/woodpecker-agent", "ping"] ENTRYPOINT ["/bin/woodpecker-agent"] diff --git a/docs/docs/30-administration/15-agent-config.md b/docs/docs/30-administration/15-agent-config.md index 6d1394098..ed6526663 100644 --- a/docs/docs/30-administration/15-agent-config.md +++ b/docs/docs/30-administration/15-agent-config.md @@ -58,7 +58,7 @@ A shared secret used by server and agents to authenticate communication. A secre ### `WOODPECKER_AGENT_SECRET_FILE` > Default: empty -Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath +Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath, e.g. `/etc/woodpecker/agent-secret.conf` ### `WOODPECKER_LOG_LEVEL` > Default: empty @@ -80,6 +80,11 @@ Disable colored debug output. Configures the agent hostname. +### `WOODPECKER_AGENT_ID_FILE` +> Default: `/etc/woodpecker/agent-id.conf` + +Configures the path of the agent-id.conf file. + ### `WOODPECKER_MAX_WORKFLOWS` > Default: `1` diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index f4dfb3611..b8f421204 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -342,6 +342,7 @@ "agents": "Agents", "desc": "Agents registered for this server", "none": "There are no agents yet.", + "id": "ID", "add": "Add agent", "save": "Save agent", "show": "Show agents", diff --git a/web/src/components/admin/settings/AdminAgentsTab.vue b/web/src/components/admin/settings/AdminAgentsTab.vue index 6492bd0f0..8598173b2 100644 --- a/web/src/components/admin/settings/AdminAgentsTab.vue +++ b/web/src/components/admin/settings/AdminAgentsTab.vue @@ -66,6 +66,10 @@ + + + +