blob: 1f09da1e1ba2d268afe02b90c377ee9829881ff7 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package execution
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/apache/airflow/go-sdk/pkg/execution/genmodels"
"github.com/apache/airflow/go-sdk/sdk"
)
// Supervisor-side error codes carried in the "error" field of an
// ErrorResponse frame. The Python source of truth is
// airflow.sdk.exceptions.ErrorType.
const (
errCodeVariableNotFound = "VARIABLE_NOT_FOUND"
errCodeConnectionNotFound = "CONNECTION_NOT_FOUND"
errCodeXComNotFound = "XCOM_NOT_FOUND"
)
// translateAPIError converts a supervisor *APIError whose Err field matches
// code into a sentinel-wrapped error. Any other error - including a
// *APIError with a different code - is returned unchanged so callers can keep
// distinguishing transport / server errors from "thing not found".
func translateAPIError(err error, code string, sentinel error, key string) error {
if err == nil {
return nil
}
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.Err == code {
return fmt.Errorf("%w: %q", sentinel, key)
}
return err
}
// CoordinatorClient implements sdk.Client by communicating with the Airflow supervisor
// over the comm socket using msgpack-framed IPC instead of HTTP.
type CoordinatorClient struct {
comm *CoordinatorComm
}
var _ sdk.Client = (*CoordinatorClient)(nil)
// NewCoordinatorClient creates a new client backed by the comm socket.
func NewCoordinatorClient(comm *CoordinatorComm) *CoordinatorClient {
return &CoordinatorClient{
comm: comm,
}
}
// GetVariable requests a variable value from the supervisor.
func (c *CoordinatorClient) GetVariable(ctx context.Context, key string) (string, error) {
if env, ok := os.LookupEnv(sdk.VariableEnvPrefix + strings.ToUpper(key)); ok {
return env, nil
}
resp, err := c.comm.Communicate(
ctx,
genmodels.GetVariable{Key: key},
)
if err != nil {
return "", translateAPIError(err, errCodeVariableNotFound, sdk.VariableNotFound, key)
}
var result genmodels.VariableResult
if err := decodeBody(resp, &result); err != nil {
return "", fmt.Errorf("decoding variable result: %w", err)
}
if result.Value == nil {
return "", fmt.Errorf("%w: %q", sdk.VariableNotFound, key)
}
// TODO: register secret-named variables with a SecretsMasker so the
// returned value is automatically redacted from subsequent task logs,
// matching Python's airflow.models.variable.Variable.get behaviour.
switch v := result.Value.(type) {
case string:
return v, nil
default:
// Airflow Variables are stored as strings, but the supervisor
// decodes msgpack into native Go types — a supervisor that
// returns a list/map/number means the caller stored JSON.
// Re-encode it so UnmarshalJSONVariable still works uniformly
// across HTTP and coordinator modes.
b, err := json.Marshal(v)
if err != nil {
return "", fmt.Errorf("marshaling variable value: %w", err)
}
return string(b), nil
}
}
// UnmarshalJSONVariable gets a variable and unmarshals its JSON value.
func (c *CoordinatorClient) UnmarshalJSONVariable(
ctx context.Context,
key string,
pointer any,
) error {
val, err := c.GetVariable(ctx, key)
if err != nil {
return err
}
return json.Unmarshal([]byte(val), pointer)
}
// SetVariable asks the supervisor to store a variable value.
func (c *CoordinatorClient) SetVariable(
ctx context.Context,
key, value, description string,
) error {
msg := genmodels.PutVariable{Key: key, Value: value}
if description != "" {
msg.Description = description
}
_, err := c.comm.Communicate(ctx, msg)
return err
}
// DeleteVariable asks the supervisor to delete a variable.
func (c *CoordinatorClient) DeleteVariable(ctx context.Context, key string) error {
_, err := c.comm.Communicate(ctx, genmodels.DeleteVariable{Key: key})
return err
}
// GetConnection requests a connection from the supervisor.
func (c *CoordinatorClient) GetConnection(
ctx context.Context,
connID string,
) (sdk.Connection, error) {
resp, err := c.comm.Communicate(
ctx,
genmodels.GetConnection{ConnID: connID},
)
if err != nil {
return sdk.Connection{}, translateAPIError(
err, errCodeConnectionNotFound, sdk.ConnectionNotFound, connID,
)
}
var result genmodels.ConnectionResult
if err := decodeBody(resp, &result); err != nil {
return sdk.Connection{}, fmt.Errorf("decoding connection result: %w", err)
}
conn := sdk.Connection{
ID: result.ConnID,
Type: result.ConnType,
Host: ifaceString(result.Host),
Port: ifaceInt(result.Port, 0),
Path: ifaceString(result.Schema),
}
// Preserve the null-vs-empty distinction on credentials so an explicitly
// empty credential (distinct from "no credential set") survives the
// coordinator hop and reaches sdk.Connection's URI-building code the same
// way it does in Airflow. The supervisor schema types these as
// nullable strings, decoded here from the generated `any` fields.
conn.Login = ifaceStringPtr(result.Login)
conn.Password = ifaceStringPtr(result.Password)
if extra := ifaceString(result.Extra); extra != "" {
conn.Extra = map[string]any{}
if err := json.Unmarshal([]byte(extra), &conn.Extra); err != nil {
return conn, fmt.Errorf("parsing connection extra: %w", err)
}
}
// TODO: register conn.Password and sensitive-keyed entries of conn.Extra
// with a SecretsMasker so they are auto-redacted from subsequent task
// logs, matching Python's airflow.models.connection.Connection.get
// behaviour.
return conn, nil
}
// GetXCom requests an XCom value from the supervisor.
func (c *CoordinatorClient) GetXCom(
ctx context.Context,
dagID, runID, taskID string,
mapIndex *int,
key string,
_ any,
) (any, error) {
msg := genmodels.GetXCom{
Key: key,
DagID: dagID,
TaskID: taskID,
RunID: runID,
}
// Assign the pointer, not the dereferenced int: map_index is a nullable
// interface{} field and msgpack's omitempty treats an interface{} holding
// int(0) as empty, dropping an explicit map_index 0 so the supervisor would
// read mapped index 0 as unmapped. A *int in the interface encodes its pointee
// (0 included), and a nil pointer is still omitted.
msg.MapIndex = mapIndex
resp, err := c.comm.Communicate(ctx, msg)
if err != nil {
return nil, translateAPIError(err, errCodeXComNotFound, sdk.XComNotFound, key)
}
var result genmodels.XComResult
if err := decodeBody(resp, &result); err != nil {
return nil, fmt.Errorf("decoding xcom result: %w", err)
}
return result.Value, nil
}
// PushXCom sends an XCom value to the supervisor.
func (c *CoordinatorClient) PushXCom(
ctx context.Context,
ti sdk.TaskInstance,
key string,
value any,
) error {
msg := genmodels.SetXCom{
Key: key,
Value: value,
DagID: ti.DagID,
TaskID: ti.TaskID,
RunID: ti.RunID,
}
msg.MapIndex = omittedMapIndex(ti.MapIndex)
_, err := c.comm.Communicate(ctx, msg)
return err
}
// deleteXCom asks the supervisor to delete the XCom of ti with the given key. Like PushXCom, it
// leaves map_index out for an unmapped task instance, and the Execution API then deletes the XCom
// with map_index -1.
func (c *CoordinatorClient) deleteXCom(ctx context.Context, ti sdk.TaskInstance, key string) error {
msg := genmodels.DeleteXCom{
Key: key,
DagID: ti.DagID,
TaskID: ti.TaskID,
RunID: ti.RunID,
}
msg.MapIndex = omittedMapIndex(ti.MapIndex)
_, err := c.comm.Communicate(ctx, msg)
return err
}
// omittedMapIndex returns mapIndex, or nil for the unmapped sentinel -1, so that msgpack omits
// map_index from the payload instead of sending it. An explicit index 0 survives omitempty because
// the pointer, not the dereferenced int, is returned (see GetXCom).
func omittedMapIndex(mapIndex *int) *int {
if mapIndex == nil || *mapIndex == -1 {
return nil
}
return mapIndex
}
// skipDownstreamTasks asks the supervisor to mark the tasks with the given task_ids as skipped
// in the Dag run of the running task. Airflow does not change a task instance that is running,
// has succeeded or has failed.
func (c *CoordinatorClient) skipDownstreamTasks(ctx context.Context, taskIDs []string) error {
_, err := c.comm.Communicate(ctx, genmodels.SkipDownstreamTasks{Tasks: taskIDs})
return err
}